This commit is contained in:
BaekRyang 2025-11-04 10:15:34 +09:00
commit 808ddd99ec
28 changed files with 3331 additions and 0 deletions

View File

@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(npm install:*)",
"Bash(mkdir:*)",
"Bash(npm run build:*)",
"Bash(dir:*)",
"Bash(tree:*)"
],
"deny": [],
"ask": []
}
}

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
/tmp
/out-tsc
/dist
/node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/.pnp
.pnp.js
.vscode/*
.DS_Store
*.log

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# 디폴트 무시된 파일
/shelf/
/workspace.xml
# 에디터 기반 HTTP 클라이언트 요청
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

7
.idea/discord.xml generated Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="ASK" />
<option name="description" value="" />
</component>
</project>

View File

@ -0,0 +1,29 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="IncorrectHttpHeaderInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="customHeaders">
<set>
<option value="&quot;name&quot;" />
</set>
</option>
</inspection_tool>
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<value>
<list size="1">
<item index="0" class="java.lang.String" itemvalue="uvloop" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="PyPep8Inspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="N803" />
</list>
</option>
</inspection_tool>
</profile>
</component>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/responsive-image-canvas.iml" filepath="$PROJECT_DIR$/.idea/responsive-image-canvas.iml" />
</modules>
</component>
</project>

12
.idea/responsive-image-canvas.iml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

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

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1
CLAUDE.md Normal file
View File

@ -0,0 +1 @@
- 주석은 한글로 작성

254
README.md Normal file
View File

@ -0,0 +1,254 @@
# Responsive Image Canvas
GPU 가속 이미지 왜곡 효과를 제공하는 React 컴포넌트 라이브러리입니다. Three.js와 GLSL 셰이더를 사용하여 실시간 이미지 왜곡 애니메이션을 구현합니다.
## 특징
- 🚀 GPU 가속 렌더링 (Three.js + WebGL)
- 🎨 최대 8개의 독립적인 왜곡 영역 지원
- ⚡ 60fps 실시간 애니메이션
- 🎯 정규화된 좌표계 (0.0 - 1.0)
- 🔧 TypeScript 완벽 지원
- 📦 ESM & CommonJS 모두 지원
## 설치
```bash
npm install responsive-image-canvas
```
### Peer Dependencies
```bash
npm install react react-dom three
```
## 기본 사용법
```tsx
import { ImageDistortion, DistortionArea } from 'responsive-image-canvas';
const areas: DistortionArea[] = [
{
id: 'area-1',
basePoints: [
{ 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: {
vectorA: { x: 0.1, y: 0.1 },
vectorB: { x: -0.1, y: -0.1 },
duration: 2.0,
easing: 'easeInOut',
},
distortionStrength: 0.5,
progress: 0,
dragVector: { x: 0, y: 0 },
},
];
function App() {
return (
<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 (정규화된 좌표)
}
```
### `DistortionMovement`
```typescript
interface DistortionMovement {
vectorA: Point; // 시작 벡터
vectorB: Point; // 종료 벡터
duration: number; // 지속 시간 (초)
easing: EasingFunction; // 이징 함수
}
```
### `EasingFunction`
```typescript
type EasingFunction =
| 'linear'
| 'easeIn'
| 'easeOut'
| 'easeInOut'
| 'easeInQuad'
| 'easeOutQuad';
```
## 고급 사용법
### 영역 동적 추가/제거
```tsx
function DynamicDistortion() {
const [areas, setAreas] = useState<DistortionArea[]>([]);
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 },
};
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';
// 기본 설정값 사용
const newArea = {
...DEFAULT_AREA,
id: 'my-area',
basePoints: [/* ... */],
};
// 이징 함수 직접 사용
const easedValue = applyEasing(0.5, 'easeInOut');
console.log(easedValue); // 0.5
```
## 셰이더 파일
패키지는 기본 셰이더 파일을 포함하고 있습니다:
- `dist/distortion.vert.glsl` - 버텍스 셰이더
- `dist/distortion.frag.glsl` - 프래그먼트 셰이더
웹 서버에서 이 파일들을 정적 파일로 제공해야 합니다.
### Vite 설정 예시
```typescript
// 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개의 왜곡 영역만 지원합니다
## 브라우저 지원
- 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)

2125
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
package.json Normal file
View File

@ -0,0 +1,48 @@
{
"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",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"three": "^0.150.0"
},
"devDependencies": {
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@types/three": "^0.181.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"three": "^0.181.0",
"tsup": "^8.5.0",
"typescript": "^5.5.3"
},
"keywords": [
"react",
"three.js",
"webgl",
"shader",
"image-distortion",
"canvas",
"animation"
],
"author": "",
"license": "MIT"
}

View File

@ -0,0 +1,180 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as THREE from 'three';
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
*/
export interface ImageDistortionProps {
/** 이미지 소스 URL */
imageSrc: string;
/** 왜곡 영역 배열 */
areas: DistortionArea[];
/** 버텍스 셰이더 경로 (선택사항) */
vertexShaderPath?: string;
/** 프래그먼트 셰이더 경로 (선택사항) */
fragmentShaderPath?: string;
/** 애니메이션 재생 여부 */
isPlaying?: boolean;
/** 컨테이너 스타일 */
style?: React.CSSProperties;
/** 컨테이너 클래스명 */
className?: string;
}
/**
* GPU
* Three.js와 GLSL .
*/
export const ImageDistortion: React.FC<ImageDistortionProps> = ({
imageSrc,
areas,
vertexShaderPath,
fragmentShaderPath,
isPlaying = true,
style,
className,
}) => {
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 [isReady, setIsReady] = useState(false);
const [currentAreas, setCurrentAreas] = useState<DistortionArea[]>(areas);
// 영역 변경 시 상태 업데이트
useEffect(() => {
setCurrentAreas(areas);
}, [areas]);
// Three.js 씬 초기화
useEffect(() => {
if (!containerRef.current) return;
const scene = new ThreeScene(containerRef.current);
sceneRef.current = scene;
// 셰이더 로드
const vertPath = vertexShaderPath || '/shaders/distortion.vert.glsl';
const fragPath = fragmentShaderPath || '/shaders/distortion.frag.glsl';
shaderManagerRef.current
.loadShaders(vertPath, fragPath)
.then(({ vertex, fragment }) => {
scene.setShaderMaterial(vertex, fragment);
setIsReady(true);
})
.catch((error) => {
console.error('셰이더 로드 실패:', error);
});
return () => {
scene.dispose();
if (textureRef.current) {
textureRef.current.dispose();
}
};
}, [vertexShaderPath, fragmentShaderPath]);
// 이미지 텍스처 로드
useEffect(() => {
if (!imageSrc || !isReady) return;
const loader = new THREE.TextureLoader();
loader.load(
imageSrc,
(texture) => {
textureRef.current = texture;
if (sceneRef.current) {
sceneRef.current.updateUniforms({
u_texture: { value: texture },
});
sceneRef.current.render();
}
},
undefined,
(error) => {
console.error('이미지 로드 실패:', error);
}
);
return () => {
if (textureRef.current) {
textureRef.current.dispose();
textureRef.current = null;
}
};
}, [imageSrc, isReady]);
// 셰이더 유니폼 업데이트
useEffect(() => {
if (!sceneRef.current || !isReady) return;
// 포인트 배열 생성
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] = point.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;
});
// 강도 배열 생성
const strengths = new Float32Array(SHADER_CONFIG.MAX_STRENGTHS);
currentAreas.forEach((area, index) => {
strengths[index] = area.distortionStrength;
});
sceneRef.current.updateUniforms({
u_numAreas: { value: currentAreas.length },
u_points: { value: points },
u_dragVectors: { value: dragVectors },
u_distortionStrengths: { value: strengths },
});
sceneRef.current.render();
}, [currentAreas, isReady]);
// 애니메이션 루프
const animationCallback = useCallback((deltaTime: number) => {
if (!isReady) return;
// 진행도 업데이트
const updatedAreas = AnimationLoop.updateProgress(currentAreas, deltaTime);
// 드래그 벡터 업데이트
const areasWithVectors = AnimationLoop.updateAreaDragVectors(updatedAreas);
setCurrentAreas(areasWithVectors);
}, [currentAreas, isReady]);
useAnimationFrame(animationCallback, isPlaying);
return (
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
position: 'relative',
...style,
}}
className={className}
/>
);
};

View File

@ -0,0 +1,68 @@
import { DistortionArea, Point } from '../types';
import { applyEasing } from '../utils/easing';
/**
*
*/
export class AnimationLoop {
/**
*
* @param areas
* @returns
*/
public static updateAreaDragVectors(
areas: DistortionArea[]
): DistortionArea[] {
return areas.map((area) => {
const { progress, movement } = area;
// 이징 적용
const easedProgress = applyEasing(progress, movement.easing);
// 벡터 간 보간
let dragVector: Point;
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 {
// 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 {
...area,
dragVector,
};
});
}
/**
*
* @param areas
* @param deltaTime ()
* @returns
*/
public static updateProgress(
areas: DistortionArea[],
deltaTime: number
): DistortionArea[] {
return areas.map((area) => {
let newProgress = area.progress + deltaTime / area.movement.duration;
newProgress %= 1.0; // 루프
return {
...area,
progress: newProgress,
};
});
}
}

View File

@ -0,0 +1,63 @@
/**
*
*/
export class ShaderManager {
private vertexShaderSource: string | null = null;
private fragmentShaderSource: string | null = null;
/**
*
* @param vertexPath
* @param fragmentPath
* @returns
*/
public async loadShaders(
vertexPath: string,
fragmentPath: string
): Promise<{ vertex: string; fragment: string }> {
try {
const [vertexResponse, fragmentResponse] = await Promise.all([
fetch(vertexPath),
fetch(fragmentPath),
]);
if (!vertexResponse.ok) {
throw new Error(`버텍스 셰이더 로드 실패: ${vertexResponse.statusText}`);
}
if (!fragmentResponse.ok) {
throw new Error(`프래그먼트 셰이더 로드 실패: ${fragmentResponse.statusText}`);
}
this.vertexShaderSource = await vertexResponse.text();
this.fragmentShaderSource = await fragmentResponse.text();
return {
vertex: this.vertexShaderSource,
fragment: this.fragmentShaderSource,
};
} catch (error) {
console.error('셰이더 로드 실패:', error);
throw new Error('셰이더 로딩에 실패했습니다');
}
}
/**
*
*/
public getVertexShader(): string {
if (!this.vertexShaderSource) {
throw new Error('버텍스 셰이더가 로드되지 않았습니다');
}
return this.vertexShaderSource;
}
/**
*
*/
public getFragmentShader(): string {
if (!this.fragmentShaderSource) {
throw new Error('프래그먼트 셰이더가 로드되지 않았습니다');
}
return this.fragmentShaderSource;
}
}

111
src/engine/ThreeScene.ts Normal file
View File

@ -0,0 +1,111 @@
import * as THREE from 'three';
import { ShaderUniforms } from '@/types';
/**
* Three.js
*/
export class ThreeScene {
private scene: THREE.Scene;
private camera: THREE.OrthographicCamera;
private renderer: THREE.WebGLRenderer;
private mesh: THREE.Mesh | null = null;
private uniforms: ShaderUniforms;
constructor(private container: HTMLElement) {
// 씬 생성
this.scene = new THREE.Scene();
// 2D용 직교 카메라 설정
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
// 렌더러 설정
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.container.appendChild(this.renderer.domElement);
// 유니폼 초기화
this.uniforms = {
u_resolution: { value: new THREE.Vector2() },
u_texture: { value: null },
u_points: { value: new Float32Array(64) }, // 32포인트 × 2(x,y)
u_numAreas: { value: 0 },
u_dragVectors: { value: new Float32Array(16) }, // 8벡터 × 2(x,y)
u_distortionStrengths: { value: new Float32Array(8) },
};
this.handleResize();
window.addEventListener('resize', this.handleResize);
}
/**
*
*/
private handleResize = () => {
const width = this.container.clientWidth;
const height = this.container.clientHeight;
this.renderer.setSize(width, height);
this.uniforms.u_resolution.value.set(width, height);
if (this.mesh) {
this.render();
}
};
/**
*
* @param vertexShader
* @param fragmentShader
*/
public setShaderMaterial(vertexShader: string, fragmentShader: string) {
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader,
fragmentShader,
});
if (this.mesh) {
this.scene.remove(this.mesh);
}
this.mesh = new THREE.Mesh(geometry, material);
this.scene.add(this.mesh);
}
/**
*
* @param updates
*/
public updateUniforms(updates: Partial<ShaderUniforms>) {
Object.keys(updates).forEach((key) => {
const uniformKey = key as keyof ShaderUniforms;
this.uniforms[uniformKey].value = updates[uniformKey]!.value;
});
}
/**
*
*/
public render() {
this.renderer.render(this.scene, this.camera);
}
/**
*
*/
public dispose() {
window.removeEventListener('resize', this.handleResize);
this.renderer.dispose();
if (this.mesh) {
this.mesh.geometry.dispose();
(this.mesh.material as THREE.Material).dispose();
}
if (this.container.contains(this.renderer.domElement)) {
this.container.removeChild(this.renderer.domElement);
}
}
}

View File

@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react';
/**
* requestAnimationFrame을
* @param callback (deltaTime을 )
* @param isPlaying
*/
export const useAnimationFrame = (
callback: (deltaTime: number) => void,
isPlaying: boolean = true
) => {
const requestRef = useRef<number | undefined>(undefined);
const previousTimeRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (!isPlaying) return;
const animate = (time: number) => {
if (previousTimeRef.current !== undefined) {
const deltaTime = (time - previousTimeRef.current) / 1000; // 밀리초를 초로 변환
callback(deltaTime);
}
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(animate);
};
requestRef.current = requestAnimationFrame(animate);
return () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
}
};
}, [callback, isPlaying]);
};

28
src/index.ts Normal file
View File

@ -0,0 +1,28 @@
// 메인 컴포넌트
export { ImageDistortion } from './components/ImageDistortion';
export type { ImageDistortionProps } from './components/ImageDistortion';
// 타입 정의
export type {
Point,
EasingFunction,
DistortionMovement,
DistortionArea,
AreaBounds,
ShaderUniforms,
ShaderConfig,
AnimationState,
AnimationTicker,
} from './types';
// 유틸리티 함수
export { applyEasing } from './utils/easing';
export { SHADER_CONFIG, ANIMATION_CONFIG, DEFAULT_AREA } from './utils/constants';
// 엔진 클래스 (고급 사용자용)
export { ThreeScene } from './engine/ThreeScene';
export { ShaderManager } from './engine/ShaderManager';
export { AnimationLoop } from './engine/AnimationLoop';
// 훅
export { useAnimationFrame } from './hooks/useAnimationFrame';

View File

@ -0,0 +1,88 @@
uniform vec2 u_resolution;
uniform sampler2D u_texture;
uniform vec2 u_points[32]; // 최대 8영역 × 4포인트
uniform int u_numAreas;
uniform vec2 u_dragVectors[8];
uniform float u_distortionStrengths[8];
varying vec2 vUv;
// 사각형 내부의 포인트에 대한 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);
}
// 초기 추정값 (정규화된 좌표)
vec2 rectSize = maxP - minP;
vec2 rectUV = (xy - minP) / rectSize;
float u0 = rectUV.x;
float v0 = rectUV.y;
// 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;
float det = du_vec.x * dv_vec.y - du_vec.y * dv_vec.x;
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;
u0 += du;
v0 += dv;
}
// 포인트가 내부에 있는지 확인
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 uv = vUv;
vec2 pixelCoord = vUv * u_resolution;
// 모든 영역의 왜곡 적용
for (int i = 0; i < 8; i++) {
if (i >= u_numAreas) break;
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 areaUV = computeUV(pixelCoord, p0, p1, p2, p3);
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)
// 부드러운 감쇠
float influence = 1.0 - smoothstep(0.0, maxUvRadius, distToCenter);
// 왜곡 적용
vec2 distortion = (u_dragVectors[i] / u_resolution) * influence * u_distortionStrengths[i];
uv += distortion;
}
}
// 텍스처 외부 샘플링 방지를 위한 클램핑
uv = clamp(uv, 0.0, 1.0);
// 텍스처 샘플링
gl_FragColor = texture2D(u_texture, uv);
}

View File

@ -0,0 +1,6 @@
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

27
src/types/animation.ts Normal file
View File

@ -0,0 +1,27 @@
/**
*
*/
export interface AnimationState {
/** 재생 중 여부 */
isPlaying: boolean;
/** 현재 시간 (초) */
currentTime: number;
/** 델타 타임 (프레임 간 시간 차이, 초) */
deltaTime: number;
/** 현재 FPS */
fps: number;
}
/**
*
*/
export interface AnimationTicker {
/** 애니메이션 시작 */
start: () => void;
/** 애니메이션 정지 */
stop: () => void;
/** 애니메이션 일시정지 */
pause: () => void;
/** 애니메이션 재개 */
resume: () => void;
}

60
src/types/area.ts Normal file
View File

@ -0,0 +1,60 @@
/**
* 2D (0.0 - 1.0)
*/
export interface Point {
x: number;
y: number;
}
/**
*
*/
export type EasingFunction =
| 'linear'
| 'easeIn'
| 'easeOut'
| 'easeInOut'
| 'easeInQuad'
| 'easeOutQuad';
/**
*
*/
export interface DistortionMovement {
/** 왜곡 시작 벡터 */
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;
}
/**
*
*/
export interface AreaBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}

3
src/types/index.ts Normal file
View File

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

30
src/types/shader.ts Normal file
View File

@ -0,0 +1,30 @@
import * as THREE from 'three';
/**
*
*/
export interface ShaderUniforms {
[uniform: string]: THREE.IUniform<any>;
/** 화면 해상도 */
u_resolution: THREE.IUniform<THREE.Vector2>;
/** 이미지 텍스처 */
u_texture: THREE.IUniform<THREE.Texture | null>;
/** 모든 영역의 포인트 배열 (최대 32개 포인트 = 8영역 × 4포인트) */
u_points: THREE.IUniform<Float32Array>;
/** 활성 영역 개수 */
u_numAreas: THREE.IUniform<number>;
/** 각 영역의 드래그 벡터 배열 */
u_dragVectors: THREE.IUniform<Float32Array>;
/** 각 영역의 왜곡 강도 배열 */
u_distortionStrengths: THREE.IUniform<Float32Array>;
}
/**
*
*/
export interface ShaderConfig {
/** 최대 영역 개수 */
maxAreas: number;
/** 최대 포인트 개수 (maxAreas × 4) */
maxPoints: number;
}

39
src/utils/constants.ts Normal file
View File

@ -0,0 +1,39 @@
/**
*
*/
export const SHADER_CONFIG = {
/** 최대 영역 개수 */
MAX_AREAS: 8,
/** 최대 포인트 개수 (8영역 × 4포인트) */
MAX_POINTS: 32,
/** 최대 드래그 벡터 개수 */
MAX_DRAG_VECTORS: 8,
/** 최대 강도 배열 크기 */
MAX_STRENGTHS: 8,
} as const;
/**
*
*/
export const ANIMATION_CONFIG = {
/** 목표 FPS */
TARGET_FPS: 60,
/** 델타 타임 (약 16.67ms) */
DELTA_TIME: 1 / 60,
} as const;
/**
*
*/
export const DEFAULT_AREA = {
/** 기본 왜곡 강도 */
DISTORTION_STRENGTH: 0.5,
/** 기본 애니메이션 지속 시간 (초) */
DURATION: 2.0,
/** 기본 이징 함수 */
EASING: 'easeInOut' as const,
/** 기본 벡터 A */
VECTOR_A: { x: 0.1, y: 0.1 },
/** 기본 벡터 B */
VECTOR_B: { x: -0.1, y: -0.1 },
} as const;

31
src/utils/easing.ts Normal file
View File

@ -0,0 +1,31 @@
import { EasingFunction } from '../types';
type EasingFunc = (t: number) => number;
/**
*
*/
const easingFunctions: Record<EasingFunction, EasingFunc> = {
linear: (t) => t,
easeIn: (t) => t * t,
easeOut: (t) => t * (2 - t),
easeInOut: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t),
};
/**
*
* @param progress (0.0 - 1.0)
* @param easingType
* @returns (0.0 - 1.0)
*/
export const applyEasing = (
progress: number,
easingType: EasingFunction
): number => {
const clampedProgress = Math.max(0, Math.min(1, progress));
return easingFunctions[easingType](clampedProgress);
};

23
tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

14
tsup.config.ts Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
external: ['react', 'react-dom', 'three'],
// 셰이더 파일을 dist로 복사
publicDir: 'src/shaders',
outDir: 'dist',
});