Add sprite particle effects and improve lens distortion
- 스프라이트 기반 파티클 이펙트 관리 기능 추가 (SpriteEffectManager) - 렌즈 왜곡 셰이더 로직 개선 및 픽셀 공간 기준 등방성 확대 적용 - 파티클 최적화를 위한 오브젝트 풀링(SpriteParticlePool) 도입 - DistortionArea 타입에 spriteEffects 설정 필드 추가 - ThreeScene에 씬 객체 접근 기능 및 렌더 순서 제어 추가 - useMouseInteraction 훅에서 마우스 상태 조회 기능 추가 - 버전 1.3.0 업데이트 및 관련 타입 정의 반영
This commit is contained in:
parent
c72846b06e
commit
48fdd5e17c
27
dist/distortion.frag.glsl
vendored
27
dist/distortion.frag.glsl
vendored
@ -76,13 +76,28 @@ void main() {
|
||||
vec2 distortion = u_dragVectors[i] * influence * u_distortionStrengths[i];
|
||||
texCoord += distortion;
|
||||
|
||||
// 렌즈 왜곡 효과 (방사형 UV 왜곡)
|
||||
// 렌즈 왜곡 효과 (볼록: 중심 확대, 오목: 중심 축소)
|
||||
if (abs(u_lensEffects[i]) > 0.001) {
|
||||
vec2 centered = uv_local - vec2(0.5);
|
||||
float dist2 = dot(centered, centered);
|
||||
float lensK = u_lensEffects[i] * 2.0; // 강도 스케일링
|
||||
vec2 lensDistortion = centered * lensK * dist2;
|
||||
texCoord += lensDistortion * u_distortionStrengths[i];
|
||||
// 영역 중심의 글로벌 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
102
dist/index.d.mts
vendored
102
dist/index.d.mts
vendored
@ -1,6 +1,57 @@
|
||||
import React$1 from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
/** 이펙트 트리거 타입 */
|
||||
type SpriteEffectTrigger = 'ambient' | 'touch';
|
||||
/** 블렌드 모드 */
|
||||
type SpriteBlendMode = 'normal' | 'additive';
|
||||
/**
|
||||
* 수명 기반 파티클 속성 보간 설정
|
||||
*/
|
||||
interface SpriteParticleOverLifetime {
|
||||
/** [시작, 끝] 스케일 */
|
||||
scale?: [number, number];
|
||||
/** [시작, 끝] 투명도 */
|
||||
opacity?: [number, number];
|
||||
/** 회전 속도 (라디안/초) */
|
||||
rotationSpeed?: number;
|
||||
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
|
||||
velocityDamping?: number;
|
||||
}
|
||||
/**
|
||||
* 스프라이트 이펙트 설정
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정규화된 좌표계의 2D 포인트 (0.0 - 1.0)
|
||||
*/
|
||||
@ -75,6 +126,8 @@ interface DistortionArea {
|
||||
};
|
||||
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
|
||||
snapSteps?: number;
|
||||
/** 스프라이트 이펙트 설정 배열 */
|
||||
spriteEffects?: SpriteEffectConfig[];
|
||||
}
|
||||
/**
|
||||
* 영역 충돌 감지를 위한 경계 상자
|
||||
@ -524,6 +577,10 @@ declare class ThreeScene {
|
||||
* @param fragmentShader 프래그먼트 셰이더 소스
|
||||
*/
|
||||
setShaderMaterial(vertexShader: string, fragmentShader: string): void;
|
||||
/**
|
||||
* Three.js 씬 객체 반환
|
||||
*/
|
||||
getScene(): THREE.Scene;
|
||||
/**
|
||||
* 유니폼 값 업데이트
|
||||
* @param updates 업데이트할 유니폼 값들
|
||||
@ -642,6 +699,48 @@ declare class SpringPhysics {
|
||||
returnToEquilibrium(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 터치/마우스 상태 (스프라이트 이펙트 전용)
|
||||
*/
|
||||
interface SpriteEffectTouchState {
|
||||
/** 마우스/터치 위치 (정규화 좌표, null이면 미접촉) */
|
||||
position: Point | null;
|
||||
/** 드래그 중 여부 */
|
||||
isDragging: boolean;
|
||||
}
|
||||
/**
|
||||
* 스프라이트 이펙트 전체 관리자
|
||||
* ImageDistortion 컴포넌트에서 생성하여 사용하는 최상위 진입점
|
||||
*/
|
||||
declare class SpriteEffectManager {
|
||||
/** 모든 이펙트 메쉬를 담는 그룹 */
|
||||
private effectGroup;
|
||||
/** 영역ID+이펙트ID → 인스턴스 맵 */
|
||||
private instances;
|
||||
/** 이전 프레임에서 터치 중이던 영역 ID 세트 (버스트 감지용) */
|
||||
private previousTouchingAreas;
|
||||
constructor();
|
||||
/**
|
||||
* Three.js 씬에 이펙트 그룹 추가
|
||||
*/
|
||||
attachToScene(scene: THREE.Scene): void;
|
||||
/**
|
||||
* 영역의 spriteEffects 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
*/
|
||||
syncEffects(areas: DistortionArea[]): void;
|
||||
/**
|
||||
* 매 프레임 업데이트
|
||||
* @param areas 현재 영역 배열
|
||||
* @param deltaTime 초 단위 프레임 시간
|
||||
* @param touchState 마우스/터치 상태
|
||||
*/
|
||||
update(areas: DistortionArea[], deltaTime: number, touchState: SpriteEffectTouchState): void;
|
||||
/**
|
||||
* 리소스 정리
|
||||
*/
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* requestAnimationFrame을 사용한 애니메이션 루프 훅
|
||||
* @param callback 매 프레임마다 호출될 콜백 (deltaTime을 인자로 받음)
|
||||
@ -666,6 +765,7 @@ declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement |
|
||||
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, ThreeScene, applyEasing, getRegisteredPresets, hasPreset, isRotationPreset, presetToVector, registerMotionPreset, registerMotionPresets, resetToBuiltInPresets, unregisterMotionPreset, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };
|
||||
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 SpriteEffectConfig, SpriteEffectManager, type SpriteEffectTrigger, type SpriteParticleOverLifetime, ThreeScene, applyEasing, getRegisteredPresets, hasPreset, isRotationPreset, presetToVector, registerMotionPreset, registerMotionPresets, resetToBuiltInPresets, unregisterMotionPreset, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };
|
||||
|
||||
102
dist/index.d.ts
vendored
102
dist/index.d.ts
vendored
@ -1,6 +1,57 @@
|
||||
import React$1 from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
/** 이펙트 트리거 타입 */
|
||||
type SpriteEffectTrigger = 'ambient' | 'touch';
|
||||
/** 블렌드 모드 */
|
||||
type SpriteBlendMode = 'normal' | 'additive';
|
||||
/**
|
||||
* 수명 기반 파티클 속성 보간 설정
|
||||
*/
|
||||
interface SpriteParticleOverLifetime {
|
||||
/** [시작, 끝] 스케일 */
|
||||
scale?: [number, number];
|
||||
/** [시작, 끝] 투명도 */
|
||||
opacity?: [number, number];
|
||||
/** 회전 속도 (라디안/초) */
|
||||
rotationSpeed?: number;
|
||||
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
|
||||
velocityDamping?: number;
|
||||
}
|
||||
/**
|
||||
* 스프라이트 이펙트 설정
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정규화된 좌표계의 2D 포인트 (0.0 - 1.0)
|
||||
*/
|
||||
@ -75,6 +126,8 @@ interface DistortionArea {
|
||||
};
|
||||
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
|
||||
snapSteps?: number;
|
||||
/** 스프라이트 이펙트 설정 배열 */
|
||||
spriteEffects?: SpriteEffectConfig[];
|
||||
}
|
||||
/**
|
||||
* 영역 충돌 감지를 위한 경계 상자
|
||||
@ -524,6 +577,10 @@ declare class ThreeScene {
|
||||
* @param fragmentShader 프래그먼트 셰이더 소스
|
||||
*/
|
||||
setShaderMaterial(vertexShader: string, fragmentShader: string): void;
|
||||
/**
|
||||
* Three.js 씬 객체 반환
|
||||
*/
|
||||
getScene(): THREE.Scene;
|
||||
/**
|
||||
* 유니폼 값 업데이트
|
||||
* @param updates 업데이트할 유니폼 값들
|
||||
@ -642,6 +699,48 @@ declare class SpringPhysics {
|
||||
returnToEquilibrium(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 터치/마우스 상태 (스프라이트 이펙트 전용)
|
||||
*/
|
||||
interface SpriteEffectTouchState {
|
||||
/** 마우스/터치 위치 (정규화 좌표, null이면 미접촉) */
|
||||
position: Point | null;
|
||||
/** 드래그 중 여부 */
|
||||
isDragging: boolean;
|
||||
}
|
||||
/**
|
||||
* 스프라이트 이펙트 전체 관리자
|
||||
* ImageDistortion 컴포넌트에서 생성하여 사용하는 최상위 진입점
|
||||
*/
|
||||
declare class SpriteEffectManager {
|
||||
/** 모든 이펙트 메쉬를 담는 그룹 */
|
||||
private effectGroup;
|
||||
/** 영역ID+이펙트ID → 인스턴스 맵 */
|
||||
private instances;
|
||||
/** 이전 프레임에서 터치 중이던 영역 ID 세트 (버스트 감지용) */
|
||||
private previousTouchingAreas;
|
||||
constructor();
|
||||
/**
|
||||
* Three.js 씬에 이펙트 그룹 추가
|
||||
*/
|
||||
attachToScene(scene: THREE.Scene): void;
|
||||
/**
|
||||
* 영역의 spriteEffects 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
*/
|
||||
syncEffects(areas: DistortionArea[]): void;
|
||||
/**
|
||||
* 매 프레임 업데이트
|
||||
* @param areas 현재 영역 배열
|
||||
* @param deltaTime 초 단위 프레임 시간
|
||||
* @param touchState 마우스/터치 상태
|
||||
*/
|
||||
update(areas: DistortionArea[], deltaTime: number, touchState: SpriteEffectTouchState): void;
|
||||
/**
|
||||
* 리소스 정리
|
||||
*/
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* requestAnimationFrame을 사용한 애니메이션 루프 훅
|
||||
* @param callback 매 프레임마다 호출될 콜백 (deltaTime을 인자로 받음)
|
||||
@ -666,6 +765,7 @@ declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement |
|
||||
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, ThreeScene, applyEasing, getRegisteredPresets, hasPreset, isRotationPreset, presetToVector, registerMotionPreset, registerMotionPresets, resetToBuiltInPresets, unregisterMotionPreset, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };
|
||||
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 SpriteEffectConfig, SpriteEffectManager, type SpriteEffectTrigger, type SpriteParticleOverLifetime, ThreeScene, applyEasing, getRegisteredPresets, hasPreset, isRotationPreset, presetToVector, registerMotionPreset, registerMotionPresets, resetToBuiltInPresets, unregisterMotionPreset, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };
|
||||
|
||||
411
dist/index.js
vendored
411
dist/index.js
vendored
@ -41,6 +41,7 @@ __export(index_exports, {
|
||||
SHADER_CONFIG: () => SHADER_CONFIG,
|
||||
ShaderManager: () => ShaderManager,
|
||||
SpringPhysics: () => SpringPhysics,
|
||||
SpriteEffectManager: () => SpriteEffectManager,
|
||||
ThreeScene: () => ThreeScene,
|
||||
applyEasing: () => applyEasing,
|
||||
getRegisteredPresets: () => getRegisteredPresets,
|
||||
@ -60,7 +61,7 @@ module.exports = __toCommonJS(index_exports);
|
||||
|
||||
// src/components/ImageDistortion.tsx
|
||||
var import_react4 = require("react");
|
||||
var THREE2 = __toESM(require("three"));
|
||||
var THREE4 = __toESM(require("three"));
|
||||
|
||||
// src/engine/ThreeScene.ts
|
||||
var THREE = __toESM(require("three"));
|
||||
@ -132,9 +133,16 @@ var ThreeScene = class {
|
||||
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\uB97C \uC52C\uC5D0 \uCD94\uAC00\uD568");
|
||||
}
|
||||
/**
|
||||
* Three.js 씬 객체 반환
|
||||
*/
|
||||
getScene() {
|
||||
return this.scene;
|
||||
}
|
||||
/**
|
||||
* 유니폼 값 업데이트
|
||||
* @param updates 업데이트할 유니폼 값들
|
||||
@ -407,6 +415,360 @@ var AnimationLoop = class {
|
||||
}
|
||||
};
|
||||
|
||||
// src/engine/SpriteEffectManager.ts
|
||||
var THREE3 = __toESM(require("three"));
|
||||
|
||||
// src/engine/SpriteEffectInstance.ts
|
||||
var THREE2 = __toESM(require("three"));
|
||||
|
||||
// src/engine/SpriteParticlePool.ts
|
||||
var SpriteParticlePool = class {
|
||||
constructor(maxParticles) {
|
||||
this.particles = Array.from({ length: maxParticles }, (_, i) => this.createParticle(i));
|
||||
}
|
||||
/** 비활성 파티클 생성 */
|
||||
createParticle(index) {
|
||||
return {
|
||||
index,
|
||||
active: false,
|
||||
position: { x: 0, y: 0 },
|
||||
velocity: { x: 0, y: 0 },
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
age: 0,
|
||||
lifetime: 1
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 비활성 파티클을 활성화하여 반환
|
||||
* 사용 가능한 파티클이 없으면 null 반환
|
||||
*/
|
||||
acquire() {
|
||||
for (const particle of this.particles) {
|
||||
if (!particle.active) {
|
||||
particle.active = true;
|
||||
particle.age = 0;
|
||||
return particle;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* 파티클을 비활성화하여 풀로 반환
|
||||
*/
|
||||
release(particle) {
|
||||
particle.active = false;
|
||||
}
|
||||
/**
|
||||
* 활성 파티클 목록 반환
|
||||
*/
|
||||
getActiveParticles() {
|
||||
return this.particles.filter((p) => p.active);
|
||||
}
|
||||
/**
|
||||
* 활성 파티클 수
|
||||
*/
|
||||
getActiveCount() {
|
||||
let count = 0;
|
||||
for (const p of this.particles) {
|
||||
if (p.active) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
};
|
||||
|
||||
// src/engine/SpriteEffectInstance.ts
|
||||
var randomRange = (min, max) => min + Math.random() * (max - min);
|
||||
var lerp = (a, b, t) => a + (b - a) * t;
|
||||
var SpriteEffectInstance = class {
|
||||
constructor(config) {
|
||||
this.texture = null;
|
||||
this.ready = false;
|
||||
this.emitAccumulator = 0;
|
||||
this.config = config;
|
||||
this.pool = new SpriteParticlePool(config.maxParticles);
|
||||
this.group = new THREE2.Group();
|
||||
this.geometry = new THREE2.PlaneGeometry(1, 1);
|
||||
const blending = config.blendMode === "additive" ? THREE2.AdditiveBlending : THREE2.NormalBlending;
|
||||
this.material = new THREE2.MeshBasicMaterial({
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
blending,
|
||||
opacity: 0
|
||||
});
|
||||
this.meshes = Array.from({ length: config.maxParticles }, () => {
|
||||
const mesh = new THREE2.Mesh(this.geometry, this.material.clone());
|
||||
mesh.visible = false;
|
||||
mesh.renderOrder = 1;
|
||||
this.group.add(mesh);
|
||||
return mesh;
|
||||
});
|
||||
this.loadTexture(config.spriteUrl);
|
||||
}
|
||||
/** 텍스처 로드 */
|
||||
loadTexture(url) {
|
||||
const loader = new THREE2.TextureLoader();
|
||||
loader.load(
|
||||
url,
|
||||
(texture) => {
|
||||
this.texture = texture;
|
||||
for (const mesh of this.meshes) {
|
||||
mesh.material.map = texture;
|
||||
mesh.material.needsUpdate = true;
|
||||
}
|
||||
this.ready = true;
|
||||
},
|
||||
void 0,
|
||||
(error) => {
|
||||
console.error(`[SpriteEffectInstance] \uD14D\uC2A4\uCC98 \uB85C\uB4DC \uC2E4\uD328: ${url}`, error);
|
||||
}
|
||||
);
|
||||
}
|
||||
/**
|
||||
* 파티클 1개 방출
|
||||
* @param center 방출 중심 (정규화 좌표 0-1)
|
||||
*/
|
||||
emitOne(center) {
|
||||
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.scale = randomRange(config.initialScale[0], config.initialScale[1]);
|
||||
particle.rotation = 0;
|
||||
particle.opacity = 1;
|
||||
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
|
||||
particle.age = 0;
|
||||
}
|
||||
/**
|
||||
* ambient 모드: 매 프레임 누적기 기반 방출
|
||||
*/
|
||||
updateAmbientEmit(deltaTime, center) {
|
||||
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) {
|
||||
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)
|
||||
*/
|
||||
update(deltaTime, emitCenter) {
|
||||
if (!this.ready) return;
|
||||
if (this.config.trigger === "ambient") {
|
||||
this.updateAmbientEmit(deltaTime, emitCenter);
|
||||
}
|
||||
const overLifetime = this.config.overLifetime;
|
||||
const activeParticles = this.pool.getActiveParticles();
|
||||
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;
|
||||
if (overLifetime) {
|
||||
if (overLifetime.scale) {
|
||||
particle.scale = lerp(overLifetime.scale[0], overLifetime.scale[1], lifeRatio);
|
||||
}
|
||||
if (overLifetime.opacity) {
|
||||
particle.opacity = lerp(overLifetime.opacity[0], overLifetime.opacity[1], lifeRatio);
|
||||
}
|
||||
if (overLifetime.rotationSpeed) {
|
||||
particle.rotation += overLifetime.rotationSpeed * deltaTime;
|
||||
}
|
||||
if (overLifetime.velocityDamping !== void 0) {
|
||||
const damping = Math.pow(overLifetime.velocityDamping, deltaTime);
|
||||
particle.velocity.x *= damping;
|
||||
particle.velocity.y *= damping;
|
||||
}
|
||||
}
|
||||
particle.position.x += particle.velocity.x * deltaTime;
|
||||
particle.position.y += particle.velocity.y * deltaTime;
|
||||
this.syncMesh(particle);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 파티클 상태를 Three.js 메쉬에 동기화
|
||||
* 정규화 좌표(0-1) → NDC(-1~1) 변환, y축 반전
|
||||
*/
|
||||
syncMesh(particle) {
|
||||
const mesh = this.meshes[particle.index];
|
||||
if (!mesh) return;
|
||||
if (!particle.active) {
|
||||
mesh.visible = false;
|
||||
return;
|
||||
}
|
||||
mesh.visible = true;
|
||||
mesh.position.x = particle.position.x * 2 - 1;
|
||||
mesh.position.y = -(particle.position.y * 2 - 1);
|
||||
mesh.position.z = 0.1;
|
||||
mesh.scale.set(particle.scale, particle.scale, 1);
|
||||
mesh.rotation.z = particle.rotation;
|
||||
const mat = mesh.material;
|
||||
mat.opacity = particle.opacity;
|
||||
}
|
||||
/**
|
||||
* 텍스처 로딩 완료 여부
|
||||
*/
|
||||
isReady() {
|
||||
return this.ready;
|
||||
}
|
||||
/**
|
||||
* 리소스 정리
|
||||
*/
|
||||
dispose() {
|
||||
if (this.texture) {
|
||||
this.texture.dispose();
|
||||
this.texture = null;
|
||||
}
|
||||
this.geometry.dispose();
|
||||
for (const mesh of this.meshes) {
|
||||
mesh.material.dispose();
|
||||
}
|
||||
this.material.dispose();
|
||||
while (this.group.children.length > 0) {
|
||||
this.group.remove(this.group.children[0]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// src/engine/SpriteEffectManager.ts
|
||||
var isPointInPolygon = (point, polygon) => {
|
||||
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;
|
||||
};
|
||||
var getAreaCenter = (area) => {
|
||||
const pts = area.basePoints;
|
||||
return {
|
||||
x: (pts[0].x + pts[1].x + pts[2].x + pts[3].x) / 4,
|
||||
y: (pts[0].y + pts[1].y + pts[2].y + pts[3].y) / 4
|
||||
};
|
||||
};
|
||||
var SpriteEffectManager = class {
|
||||
constructor() {
|
||||
/** 영역ID+이펙트ID → 인스턴스 맵 */
|
||||
this.instances = /* @__PURE__ */ new Map();
|
||||
/** 이전 프레임에서 터치 중이던 영역 ID 세트 (버스트 감지용) */
|
||||
this.previousTouchingAreas = /* @__PURE__ */ new Set();
|
||||
this.effectGroup = new THREE3.Group();
|
||||
this.effectGroup.renderOrder = 1;
|
||||
}
|
||||
/**
|
||||
* Three.js 씬에 이펙트 그룹 추가
|
||||
*/
|
||||
attachToScene(scene) {
|
||||
scene.add(this.effectGroup);
|
||||
}
|
||||
/**
|
||||
* 영역의 spriteEffects 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
*/
|
||||
syncEffects(areas) {
|
||||
const activeKeys = /* @__PURE__ */ new Set();
|
||||
for (const area of areas) {
|
||||
if (!area.spriteEffects) continue;
|
||||
for (const effectConfig of area.spriteEffects) {
|
||||
const key = `${area.id}::${effectConfig.id}`;
|
||||
activeKeys.add(key);
|
||||
if (this.instances.has(key)) continue;
|
||||
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 areas 현재 영역 배열
|
||||
* @param deltaTime 초 단위 프레임 시간
|
||||
* @param touchState 마우스/터치 상태
|
||||
*/
|
||||
update(areas, deltaTime, touchState) {
|
||||
const currentTouchingAreas = /* @__PURE__ */ new Set();
|
||||
if (touchState.isDragging && touchState.position) {
|
||||
for (const area of areas) {
|
||||
if (isPointInPolygon(touchState.position, area.basePoints)) {
|
||||
currentTouchingAreas.add(area.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const area of areas) {
|
||||
if (!area.spriteEffects) continue;
|
||||
const center = getAreaCenter(area);
|
||||
for (const effectConfig of area.spriteEffects) {
|
||||
const key = `${area.id}::${effectConfig.id}`;
|
||||
const instance = this.instances.get(key);
|
||||
if (!instance) continue;
|
||||
if (effectConfig.trigger === "touch") {
|
||||
const isNewTouch = currentTouchingAreas.has(area.id) && !this.previousTouchingAreas.has(area.id);
|
||||
if (isNewTouch) {
|
||||
instance.triggerBurst(touchState.position ?? center);
|
||||
}
|
||||
}
|
||||
instance.update(deltaTime, center);
|
||||
}
|
||||
}
|
||||
this.previousTouchingAreas = currentTouchingAreas;
|
||||
}
|
||||
/**
|
||||
* 리소스 정리
|
||||
*/
|
||||
dispose() {
|
||||
for (const [, instance] of this.instances) {
|
||||
instance.dispose();
|
||||
}
|
||||
this.instances.clear();
|
||||
this.previousTouchingAreas.clear();
|
||||
if (this.effectGroup.parent) {
|
||||
this.effectGroup.parent.remove(this.effectGroup);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// src/hooks/useAnimationFrame.ts
|
||||
var import_react = require("react");
|
||||
var useAnimationFrame = (callback, isPlaying = true) => {
|
||||
@ -677,7 +1039,7 @@ var SpringPhysics = class {
|
||||
};
|
||||
|
||||
// src/hooks/useMouseInteraction.ts
|
||||
var isPointInPolygon = (point, polygon) => {
|
||||
var isPointInPolygon2 = (point, polygon) => {
|
||||
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;
|
||||
@ -710,7 +1072,7 @@ var useMouseInteraction = (containerRef, config) => {
|
||||
if (mouseState.isDragging && mouseState.position) {
|
||||
const currentlyInAreas = /* @__PURE__ */ new Set();
|
||||
for (let i = 0; i < areas.length; i++) {
|
||||
if (isPointInPolygon(mouseState.position, areas[i].basePoints)) {
|
||||
if (isPointInPolygon2(mouseState.position, areas[i].basePoints)) {
|
||||
currentlyInAreas.add(i);
|
||||
if (!interactingAreaIndices.has(i)) {
|
||||
getSpringPhysics(i, areas[i]).reset();
|
||||
@ -816,7 +1178,8 @@ var useMouseInteraction = (containerRef, config) => {
|
||||
updateConfig,
|
||||
reset,
|
||||
isDragging,
|
||||
getInteractingAreaIndices
|
||||
getInteractingAreaIndices,
|
||||
getMouseState: getState
|
||||
};
|
||||
};
|
||||
|
||||
@ -871,6 +1234,8 @@ var ImageDistortion = ({
|
||||
const sceneRef = (0, import_react4.useRef)(null);
|
||||
const shaderManagerRef = (0, import_react4.useRef)(new ShaderManager());
|
||||
const textureRef = (0, import_react4.useRef)(null);
|
||||
const spriteManagerRef = (0, import_react4.useRef)(null);
|
||||
const currentAreasRef = (0, import_react4.useRef)(areas);
|
||||
const [isReady, setIsReady] = (0, import_react4.useState)(false);
|
||||
const [imageLoaded, setImageLoaded] = (0, import_react4.useState)(false);
|
||||
const [currentAreas, setCurrentAreas] = (0, import_react4.useState)(areas);
|
||||
@ -890,6 +1255,22 @@ var ImageDistortion = ({
|
||||
(0, import_react4.useEffect)(() => {
|
||||
setCurrentAreas(areas);
|
||||
}, [areas]);
|
||||
(0, import_react4.useEffect)(() => {
|
||||
currentAreasRef.current = currentAreas;
|
||||
}, [currentAreas]);
|
||||
(0, import_react4.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]);
|
||||
(0, import_react4.useEffect)(() => {
|
||||
spriteManagerRef.current?.syncEffects(currentAreas);
|
||||
}, [currentAreas]);
|
||||
(0, import_react4.useEffect)(() => {
|
||||
if (mouseInteraction) {
|
||||
mouseInteractionHook.updateConfig(mouseInteraction);
|
||||
@ -928,7 +1309,7 @@ var ImageDistortion = ({
|
||||
}
|
||||
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc);
|
||||
setImageLoaded(false);
|
||||
const loader = new THREE2.TextureLoader();
|
||||
const loader = new THREE4.TextureLoader();
|
||||
loader.load(
|
||||
imageSrc,
|
||||
(texture) => {
|
||||
@ -1020,6 +1401,17 @@ var ImageDistortion = ({
|
||||
}
|
||||
return updatedAreas;
|
||||
});
|
||||
if (spriteManagerRef.current) {
|
||||
const mouseState = mouseInteractionHook.getMouseState();
|
||||
spriteManagerRef.current.update(
|
||||
currentAreasRef.current,
|
||||
deltaTime,
|
||||
{
|
||||
position: mouseState.position ?? null,
|
||||
isDragging: mouseState.isDragging
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [isReady, mouseInteraction, mouseInteractionHook]);
|
||||
useAnimationFrame(animationCallback, true);
|
||||
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
||||
@ -1424,7 +1816,7 @@ var EditorCanvas = ({
|
||||
};
|
||||
}, []);
|
||||
const selectedArea = areas.find((a) => a.id === selectedAreaId);
|
||||
const isPointInPolygon2 = (0, import_react6.useCallback)((point, polygon) => {
|
||||
const isPointInPolygon3 = (0, import_react6.useCallback)((point, polygon) => {
|
||||
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;
|
||||
@ -1458,7 +1850,7 @@ var EditorCanvas = ({
|
||||
const x = (clientX - rect.left) / rect.width;
|
||||
const y = (clientY - rect.top) / rect.height;
|
||||
const clickPoint = { x, y };
|
||||
if (selectedArea && isPointInPolygon2(clickPoint, selectedArea.basePoints)) {
|
||||
if (selectedArea && isPointInPolygon3(clickPoint, selectedArea.basePoints)) {
|
||||
setIsDraggingArea(true);
|
||||
setDragStartPos(clickPoint);
|
||||
e.preventDefault();
|
||||
@ -1467,7 +1859,7 @@ var EditorCanvas = ({
|
||||
if (onSelectArea) {
|
||||
for (let i = areas.length - 1; i >= 0; i--) {
|
||||
const area = areas[i];
|
||||
if (area.id !== selectedAreaId && isPointInPolygon2(clickPoint, area.basePoints)) {
|
||||
if (area.id !== selectedAreaId && isPointInPolygon3(clickPoint, area.basePoints)) {
|
||||
onSelectArea(area.id);
|
||||
e.preventDefault();
|
||||
return;
|
||||
@ -1475,7 +1867,7 @@ var EditorCanvas = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea]
|
||||
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon3, onSelectArea]
|
||||
);
|
||||
const handleMove = (0, import_react6.useCallback)(
|
||||
(e) => {
|
||||
@ -1738,6 +2130,7 @@ var EditorCanvas = ({
|
||||
SHADER_CONFIG,
|
||||
ShaderManager,
|
||||
SpringPhysics,
|
||||
SpriteEffectManager,
|
||||
ThreeScene,
|
||||
applyEasing,
|
||||
getRegisteredPresets,
|
||||
|
||||
2
dist/index.js.map
vendored
2
dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
410
dist/index.mjs
vendored
410
dist/index.mjs
vendored
@ -1,6 +1,6 @@
|
||||
// src/components/ImageDistortion.tsx
|
||||
import { useEffect as useEffect3, useRef as useRef4, useState as useState2, useCallback as useCallback3 } from "react";
|
||||
import * as THREE2 from "three";
|
||||
import * as THREE4 from "three";
|
||||
|
||||
// src/engine/ThreeScene.ts
|
||||
import * as THREE from "three";
|
||||
@ -72,9 +72,16 @@ var ThreeScene = class {
|
||||
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\uB97C \uC52C\uC5D0 \uCD94\uAC00\uD568");
|
||||
}
|
||||
/**
|
||||
* Three.js 씬 객체 반환
|
||||
*/
|
||||
getScene() {
|
||||
return this.scene;
|
||||
}
|
||||
/**
|
||||
* 유니폼 값 업데이트
|
||||
* @param updates 업데이트할 유니폼 값들
|
||||
@ -347,6 +354,360 @@ var AnimationLoop = class {
|
||||
}
|
||||
};
|
||||
|
||||
// src/engine/SpriteEffectManager.ts
|
||||
import * as THREE3 from "three";
|
||||
|
||||
// src/engine/SpriteEffectInstance.ts
|
||||
import * as THREE2 from "three";
|
||||
|
||||
// src/engine/SpriteParticlePool.ts
|
||||
var SpriteParticlePool = class {
|
||||
constructor(maxParticles) {
|
||||
this.particles = Array.from({ length: maxParticles }, (_, i) => this.createParticle(i));
|
||||
}
|
||||
/** 비활성 파티클 생성 */
|
||||
createParticle(index) {
|
||||
return {
|
||||
index,
|
||||
active: false,
|
||||
position: { x: 0, y: 0 },
|
||||
velocity: { x: 0, y: 0 },
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
age: 0,
|
||||
lifetime: 1
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 비활성 파티클을 활성화하여 반환
|
||||
* 사용 가능한 파티클이 없으면 null 반환
|
||||
*/
|
||||
acquire() {
|
||||
for (const particle of this.particles) {
|
||||
if (!particle.active) {
|
||||
particle.active = true;
|
||||
particle.age = 0;
|
||||
return particle;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* 파티클을 비활성화하여 풀로 반환
|
||||
*/
|
||||
release(particle) {
|
||||
particle.active = false;
|
||||
}
|
||||
/**
|
||||
* 활성 파티클 목록 반환
|
||||
*/
|
||||
getActiveParticles() {
|
||||
return this.particles.filter((p) => p.active);
|
||||
}
|
||||
/**
|
||||
* 활성 파티클 수
|
||||
*/
|
||||
getActiveCount() {
|
||||
let count = 0;
|
||||
for (const p of this.particles) {
|
||||
if (p.active) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
};
|
||||
|
||||
// src/engine/SpriteEffectInstance.ts
|
||||
var randomRange = (min, max) => min + Math.random() * (max - min);
|
||||
var lerp = (a, b, t) => a + (b - a) * t;
|
||||
var SpriteEffectInstance = class {
|
||||
constructor(config) {
|
||||
this.texture = null;
|
||||
this.ready = false;
|
||||
this.emitAccumulator = 0;
|
||||
this.config = config;
|
||||
this.pool = new SpriteParticlePool(config.maxParticles);
|
||||
this.group = new THREE2.Group();
|
||||
this.geometry = new THREE2.PlaneGeometry(1, 1);
|
||||
const blending = config.blendMode === "additive" ? THREE2.AdditiveBlending : THREE2.NormalBlending;
|
||||
this.material = new THREE2.MeshBasicMaterial({
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
blending,
|
||||
opacity: 0
|
||||
});
|
||||
this.meshes = Array.from({ length: config.maxParticles }, () => {
|
||||
const mesh = new THREE2.Mesh(this.geometry, this.material.clone());
|
||||
mesh.visible = false;
|
||||
mesh.renderOrder = 1;
|
||||
this.group.add(mesh);
|
||||
return mesh;
|
||||
});
|
||||
this.loadTexture(config.spriteUrl);
|
||||
}
|
||||
/** 텍스처 로드 */
|
||||
loadTexture(url) {
|
||||
const loader = new THREE2.TextureLoader();
|
||||
loader.load(
|
||||
url,
|
||||
(texture) => {
|
||||
this.texture = texture;
|
||||
for (const mesh of this.meshes) {
|
||||
mesh.material.map = texture;
|
||||
mesh.material.needsUpdate = true;
|
||||
}
|
||||
this.ready = true;
|
||||
},
|
||||
void 0,
|
||||
(error) => {
|
||||
console.error(`[SpriteEffectInstance] \uD14D\uC2A4\uCC98 \uB85C\uB4DC \uC2E4\uD328: ${url}`, error);
|
||||
}
|
||||
);
|
||||
}
|
||||
/**
|
||||
* 파티클 1개 방출
|
||||
* @param center 방출 중심 (정규화 좌표 0-1)
|
||||
*/
|
||||
emitOne(center) {
|
||||
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.scale = randomRange(config.initialScale[0], config.initialScale[1]);
|
||||
particle.rotation = 0;
|
||||
particle.opacity = 1;
|
||||
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
|
||||
particle.age = 0;
|
||||
}
|
||||
/**
|
||||
* ambient 모드: 매 프레임 누적기 기반 방출
|
||||
*/
|
||||
updateAmbientEmit(deltaTime, center) {
|
||||
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) {
|
||||
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)
|
||||
*/
|
||||
update(deltaTime, emitCenter) {
|
||||
if (!this.ready) return;
|
||||
if (this.config.trigger === "ambient") {
|
||||
this.updateAmbientEmit(deltaTime, emitCenter);
|
||||
}
|
||||
const overLifetime = this.config.overLifetime;
|
||||
const activeParticles = this.pool.getActiveParticles();
|
||||
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;
|
||||
if (overLifetime) {
|
||||
if (overLifetime.scale) {
|
||||
particle.scale = lerp(overLifetime.scale[0], overLifetime.scale[1], lifeRatio);
|
||||
}
|
||||
if (overLifetime.opacity) {
|
||||
particle.opacity = lerp(overLifetime.opacity[0], overLifetime.opacity[1], lifeRatio);
|
||||
}
|
||||
if (overLifetime.rotationSpeed) {
|
||||
particle.rotation += overLifetime.rotationSpeed * deltaTime;
|
||||
}
|
||||
if (overLifetime.velocityDamping !== void 0) {
|
||||
const damping = Math.pow(overLifetime.velocityDamping, deltaTime);
|
||||
particle.velocity.x *= damping;
|
||||
particle.velocity.y *= damping;
|
||||
}
|
||||
}
|
||||
particle.position.x += particle.velocity.x * deltaTime;
|
||||
particle.position.y += particle.velocity.y * deltaTime;
|
||||
this.syncMesh(particle);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 파티클 상태를 Three.js 메쉬에 동기화
|
||||
* 정규화 좌표(0-1) → NDC(-1~1) 변환, y축 반전
|
||||
*/
|
||||
syncMesh(particle) {
|
||||
const mesh = this.meshes[particle.index];
|
||||
if (!mesh) return;
|
||||
if (!particle.active) {
|
||||
mesh.visible = false;
|
||||
return;
|
||||
}
|
||||
mesh.visible = true;
|
||||
mesh.position.x = particle.position.x * 2 - 1;
|
||||
mesh.position.y = -(particle.position.y * 2 - 1);
|
||||
mesh.position.z = 0.1;
|
||||
mesh.scale.set(particle.scale, particle.scale, 1);
|
||||
mesh.rotation.z = particle.rotation;
|
||||
const mat = mesh.material;
|
||||
mat.opacity = particle.opacity;
|
||||
}
|
||||
/**
|
||||
* 텍스처 로딩 완료 여부
|
||||
*/
|
||||
isReady() {
|
||||
return this.ready;
|
||||
}
|
||||
/**
|
||||
* 리소스 정리
|
||||
*/
|
||||
dispose() {
|
||||
if (this.texture) {
|
||||
this.texture.dispose();
|
||||
this.texture = null;
|
||||
}
|
||||
this.geometry.dispose();
|
||||
for (const mesh of this.meshes) {
|
||||
mesh.material.dispose();
|
||||
}
|
||||
this.material.dispose();
|
||||
while (this.group.children.length > 0) {
|
||||
this.group.remove(this.group.children[0]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// src/engine/SpriteEffectManager.ts
|
||||
var isPointInPolygon = (point, polygon) => {
|
||||
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;
|
||||
};
|
||||
var getAreaCenter = (area) => {
|
||||
const pts = area.basePoints;
|
||||
return {
|
||||
x: (pts[0].x + pts[1].x + pts[2].x + pts[3].x) / 4,
|
||||
y: (pts[0].y + pts[1].y + pts[2].y + pts[3].y) / 4
|
||||
};
|
||||
};
|
||||
var SpriteEffectManager = class {
|
||||
constructor() {
|
||||
/** 영역ID+이펙트ID → 인스턴스 맵 */
|
||||
this.instances = /* @__PURE__ */ new Map();
|
||||
/** 이전 프레임에서 터치 중이던 영역 ID 세트 (버스트 감지용) */
|
||||
this.previousTouchingAreas = /* @__PURE__ */ new Set();
|
||||
this.effectGroup = new THREE3.Group();
|
||||
this.effectGroup.renderOrder = 1;
|
||||
}
|
||||
/**
|
||||
* Three.js 씬에 이펙트 그룹 추가
|
||||
*/
|
||||
attachToScene(scene) {
|
||||
scene.add(this.effectGroup);
|
||||
}
|
||||
/**
|
||||
* 영역의 spriteEffects 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
*/
|
||||
syncEffects(areas) {
|
||||
const activeKeys = /* @__PURE__ */ new Set();
|
||||
for (const area of areas) {
|
||||
if (!area.spriteEffects) continue;
|
||||
for (const effectConfig of area.spriteEffects) {
|
||||
const key = `${area.id}::${effectConfig.id}`;
|
||||
activeKeys.add(key);
|
||||
if (this.instances.has(key)) continue;
|
||||
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 areas 현재 영역 배열
|
||||
* @param deltaTime 초 단위 프레임 시간
|
||||
* @param touchState 마우스/터치 상태
|
||||
*/
|
||||
update(areas, deltaTime, touchState) {
|
||||
const currentTouchingAreas = /* @__PURE__ */ new Set();
|
||||
if (touchState.isDragging && touchState.position) {
|
||||
for (const area of areas) {
|
||||
if (isPointInPolygon(touchState.position, area.basePoints)) {
|
||||
currentTouchingAreas.add(area.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const area of areas) {
|
||||
if (!area.spriteEffects) continue;
|
||||
const center = getAreaCenter(area);
|
||||
for (const effectConfig of area.spriteEffects) {
|
||||
const key = `${area.id}::${effectConfig.id}`;
|
||||
const instance = this.instances.get(key);
|
||||
if (!instance) continue;
|
||||
if (effectConfig.trigger === "touch") {
|
||||
const isNewTouch = currentTouchingAreas.has(area.id) && !this.previousTouchingAreas.has(area.id);
|
||||
if (isNewTouch) {
|
||||
instance.triggerBurst(touchState.position ?? center);
|
||||
}
|
||||
}
|
||||
instance.update(deltaTime, center);
|
||||
}
|
||||
}
|
||||
this.previousTouchingAreas = currentTouchingAreas;
|
||||
}
|
||||
/**
|
||||
* 리소스 정리
|
||||
*/
|
||||
dispose() {
|
||||
for (const [, instance] of this.instances) {
|
||||
instance.dispose();
|
||||
}
|
||||
this.instances.clear();
|
||||
this.previousTouchingAreas.clear();
|
||||
if (this.effectGroup.parent) {
|
||||
this.effectGroup.parent.remove(this.effectGroup);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// src/hooks/useAnimationFrame.ts
|
||||
import { useEffect, useRef } from "react";
|
||||
var useAnimationFrame = (callback, isPlaying = true) => {
|
||||
@ -617,7 +978,7 @@ var SpringPhysics = class {
|
||||
};
|
||||
|
||||
// src/hooks/useMouseInteraction.ts
|
||||
var isPointInPolygon = (point, polygon) => {
|
||||
var isPointInPolygon2 = (point, polygon) => {
|
||||
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;
|
||||
@ -650,7 +1011,7 @@ var useMouseInteraction = (containerRef, config) => {
|
||||
if (mouseState.isDragging && mouseState.position) {
|
||||
const currentlyInAreas = /* @__PURE__ */ new Set();
|
||||
for (let i = 0; i < areas.length; i++) {
|
||||
if (isPointInPolygon(mouseState.position, areas[i].basePoints)) {
|
||||
if (isPointInPolygon2(mouseState.position, areas[i].basePoints)) {
|
||||
currentlyInAreas.add(i);
|
||||
if (!interactingAreaIndices.has(i)) {
|
||||
getSpringPhysics(i, areas[i]).reset();
|
||||
@ -756,7 +1117,8 @@ var useMouseInteraction = (containerRef, config) => {
|
||||
updateConfig,
|
||||
reset,
|
||||
isDragging,
|
||||
getInteractingAreaIndices
|
||||
getInteractingAreaIndices,
|
||||
getMouseState: getState
|
||||
};
|
||||
};
|
||||
|
||||
@ -811,6 +1173,8 @@ var ImageDistortion = ({
|
||||
const sceneRef = useRef4(null);
|
||||
const shaderManagerRef = useRef4(new ShaderManager());
|
||||
const textureRef = useRef4(null);
|
||||
const spriteManagerRef = useRef4(null);
|
||||
const currentAreasRef = useRef4(areas);
|
||||
const [isReady, setIsReady] = useState2(false);
|
||||
const [imageLoaded, setImageLoaded] = useState2(false);
|
||||
const [currentAreas, setCurrentAreas] = useState2(areas);
|
||||
@ -830,6 +1194,22 @@ var ImageDistortion = ({
|
||||
useEffect3(() => {
|
||||
setCurrentAreas(areas);
|
||||
}, [areas]);
|
||||
useEffect3(() => {
|
||||
currentAreasRef.current = currentAreas;
|
||||
}, [currentAreas]);
|
||||
useEffect3(() => {
|
||||
if (!sceneRef.current || !isReady) return;
|
||||
const manager = new SpriteEffectManager();
|
||||
manager.attachToScene(sceneRef.current.getScene());
|
||||
spriteManagerRef.current = manager;
|
||||
return () => {
|
||||
manager.dispose();
|
||||
spriteManagerRef.current = null;
|
||||
};
|
||||
}, [isReady]);
|
||||
useEffect3(() => {
|
||||
spriteManagerRef.current?.syncEffects(currentAreas);
|
||||
}, [currentAreas]);
|
||||
useEffect3(() => {
|
||||
if (mouseInteraction) {
|
||||
mouseInteractionHook.updateConfig(mouseInteraction);
|
||||
@ -868,7 +1248,7 @@ var ImageDistortion = ({
|
||||
}
|
||||
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc);
|
||||
setImageLoaded(false);
|
||||
const loader = new THREE2.TextureLoader();
|
||||
const loader = new THREE4.TextureLoader();
|
||||
loader.load(
|
||||
imageSrc,
|
||||
(texture) => {
|
||||
@ -960,6 +1340,17 @@ var ImageDistortion = ({
|
||||
}
|
||||
return updatedAreas;
|
||||
});
|
||||
if (spriteManagerRef.current) {
|
||||
const mouseState = mouseInteractionHook.getMouseState();
|
||||
spriteManagerRef.current.update(
|
||||
currentAreasRef.current,
|
||||
deltaTime,
|
||||
{
|
||||
position: mouseState.position ?? null,
|
||||
isDragging: mouseState.isDragging
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [isReady, mouseInteraction, mouseInteractionHook]);
|
||||
useAnimationFrame(animationCallback, true);
|
||||
return /* @__PURE__ */ jsx(
|
||||
@ -1364,7 +1755,7 @@ var EditorCanvas = ({
|
||||
};
|
||||
}, []);
|
||||
const selectedArea = areas.find((a) => a.id === selectedAreaId);
|
||||
const isPointInPolygon2 = useCallback5((point, polygon) => {
|
||||
const isPointInPolygon3 = useCallback5((point, polygon) => {
|
||||
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;
|
||||
@ -1398,7 +1789,7 @@ var EditorCanvas = ({
|
||||
const x = (clientX - rect.left) / rect.width;
|
||||
const y = (clientY - rect.top) / rect.height;
|
||||
const clickPoint = { x, y };
|
||||
if (selectedArea && isPointInPolygon2(clickPoint, selectedArea.basePoints)) {
|
||||
if (selectedArea && isPointInPolygon3(clickPoint, selectedArea.basePoints)) {
|
||||
setIsDraggingArea(true);
|
||||
setDragStartPos(clickPoint);
|
||||
e.preventDefault();
|
||||
@ -1407,7 +1798,7 @@ var EditorCanvas = ({
|
||||
if (onSelectArea) {
|
||||
for (let i = areas.length - 1; i >= 0; i--) {
|
||||
const area = areas[i];
|
||||
if (area.id !== selectedAreaId && isPointInPolygon2(clickPoint, area.basePoints)) {
|
||||
if (area.id !== selectedAreaId && isPointInPolygon3(clickPoint, area.basePoints)) {
|
||||
onSelectArea(area.id);
|
||||
e.preventDefault();
|
||||
return;
|
||||
@ -1415,7 +1806,7 @@ var EditorCanvas = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea]
|
||||
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon3, onSelectArea]
|
||||
);
|
||||
const handleMove = useCallback5(
|
||||
(e) => {
|
||||
@ -1677,6 +2068,7 @@ export {
|
||||
SHADER_CONFIG,
|
||||
ShaderManager,
|
||||
SpringPhysics,
|
||||
SpriteEffectManager,
|
||||
ThreeScene,
|
||||
applyEasing,
|
||||
getRegisteredPresets,
|
||||
|
||||
2
dist/index.mjs.map
vendored
2
dist/index.mjs.map
vendored
File diff suppressed because one or more lines are too long
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@baekryang/responsive-image-canvas",
|
||||
"version": "1.0.5",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@baekryang/responsive-image-canvas",
|
||||
"version": "1.0.5",
|
||||
"version": "1.3.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.2",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@baekryang/responsive-image-canvas",
|
||||
"version": "1.2.10",
|
||||
"version": "1.3.0",
|
||||
"publishConfig": {
|
||||
"registry": "https://git.bnovalab.com/api/packages/baekryang/npm/"
|
||||
},
|
||||
|
||||
@ -4,6 +4,7 @@ import { type DistortionArea } 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';
|
||||
@ -46,6 +47,8 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
||||
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);
|
||||
@ -71,6 +74,28 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
||||
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(currentAreas);
|
||||
}, [currentAreas]);
|
||||
|
||||
// 마우스 인터랙션 설정 변경 시 업데이트
|
||||
useEffect(() => {
|
||||
if (mouseInteraction) {
|
||||
@ -245,6 +270,19 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
||||
|
||||
return updatedAreas;
|
||||
});
|
||||
|
||||
// 스프라이트 이펙트 업데이트 (디스토션과 독립적)
|
||||
if (spriteManagerRef.current) {
|
||||
const mouseState = mouseInteractionHook.getMouseState();
|
||||
spriteManagerRef.current.update(
|
||||
currentAreasRef.current,
|
||||
deltaTime,
|
||||
{
|
||||
position: mouseState.position ?? null,
|
||||
isDragging: mouseState.isDragging,
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [isReady, mouseInteraction, mouseInteractionHook]);
|
||||
|
||||
// 애니메이션 루프 실행
|
||||
|
||||
266
src/engine/SpriteEffectInstance.ts
Normal file
266
src/engine/SpriteEffectInstance.ts
Normal file
@ -0,0 +1,266 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Point } from '@/types';
|
||||
import type { SpriteEffectConfig } 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;
|
||||
|
||||
/**
|
||||
* 단일 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;
|
||||
});
|
||||
|
||||
// 텍스처 비동기 로드
|
||||
this.loadTexture(config.spriteUrl);
|
||||
}
|
||||
|
||||
/** 텍스처 로드 */
|
||||
private loadTexture(url: string): void {
|
||||
const loader = new THREE.TextureLoader();
|
||||
loader.load(
|
||||
url,
|
||||
(texture) => {
|
||||
this.texture = texture;
|
||||
// 모든 메쉬 머티리얼에 텍스처 적용
|
||||
for (const mesh of this.meshes) {
|
||||
(mesh.material as THREE.MeshBasicMaterial).map = texture;
|
||||
(mesh.material as THREE.MeshBasicMaterial).needsUpdate = true;
|
||||
}
|
||||
this.ready = true;
|
||||
},
|
||||
undefined,
|
||||
(error) => {
|
||||
console.error(`[SpriteEffectInstance] 텍스처 로드 실패: ${url}`, error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파티클 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.scale = randomRange(config.initialScale[0], config.initialScale[1]);
|
||||
particle.rotation = 0;
|
||||
particle.opacity = 1;
|
||||
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
|
||||
particle.age = 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)
|
||||
*/
|
||||
update(deltaTime: number, emitCenter: Point): 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();
|
||||
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) {
|
||||
particle.scale = lerp(overLifetime.scale[0], overLifetime.scale[1], lifeRatio);
|
||||
}
|
||||
if (overLifetime.opacity) {
|
||||
particle.opacity = lerp(overLifetime.opacity[0], overLifetime.opacity[1], 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 위치 업데이트
|
||||
particle.position.x += particle.velocity.x * deltaTime;
|
||||
particle.position.y += particle.velocity.y * deltaTime;
|
||||
|
||||
// Three.js 메쉬 동기화
|
||||
this.syncMesh(particle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파티클 상태를 Three.js 메쉬에 동기화
|
||||
* 정규화 좌표(0-1) → NDC(-1~1) 변환, y축 반전
|
||||
*/
|
||||
private syncMesh(particle: SpriteParticle): 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.1; // 디스토션 메쉬(z=0) 위
|
||||
|
||||
mesh.scale.set(particle.scale, particle.scale, 1);
|
||||
mesh.rotation.z = particle.rotation;
|
||||
|
||||
const mat = mesh.material as THREE.MeshBasicMaterial;
|
||||
mat.opacity = particle.opacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스처 로딩 완료 여부
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
160
src/engine/SpriteEffectManager.ts
Normal file
160
src/engine/SpriteEffectManager.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import * as THREE from 'three';
|
||||
import type { DistortionArea, Point } from '@/types';
|
||||
import { SpriteEffectInstance } from './SpriteEffectInstance';
|
||||
|
||||
/**
|
||||
* 터치/마우스 상태 (스프라이트 이펙트 전용)
|
||||
*/
|
||||
export interface SpriteEffectTouchState {
|
||||
/** 마우스/터치 위치 (정규화 좌표, null이면 미접촉) */
|
||||
position: Point | null;
|
||||
/** 드래그 중 여부 */
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 점이 사각형(볼록 다각형) 내부에 있는지 확인
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 영역 중심 좌표 계산 (basePoints 4개의 평균)
|
||||
*/
|
||||
const getAreaCenter = (area: DistortionArea): Point => {
|
||||
const pts = area.basePoints;
|
||||
return {
|
||||
x: (pts[0].x + pts[1].x + pts[2].x + pts[3].x) / 4,
|
||||
y: (pts[0].y + pts[1].y + pts[2].y + pts[3].y) / 4,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 스프라이트 이펙트 전체 관리자
|
||||
* ImageDistortion 컴포넌트에서 생성하여 사용하는 최상위 진입점
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 영역의 spriteEffects 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
*/
|
||||
syncEffects(areas: DistortionArea[]): void {
|
||||
const activeKeys = new Set<string>();
|
||||
|
||||
for (const area of areas) {
|
||||
if (!area.spriteEffects) continue;
|
||||
|
||||
for (const effectConfig of area.spriteEffects) {
|
||||
const key = `${area.id}::${effectConfig.id}`;
|
||||
activeKeys.add(key);
|
||||
|
||||
// 이미 존재하면 스킵
|
||||
if (this.instances.has(key)) continue;
|
||||
|
||||
// 새 인스턴스 생성
|
||||
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 areas 현재 영역 배열
|
||||
* @param deltaTime 초 단위 프레임 시간
|
||||
* @param touchState 마우스/터치 상태
|
||||
*/
|
||||
update(areas: DistortionArea[], deltaTime: number, touchState: SpriteEffectTouchState): void {
|
||||
// 현재 터치 중인 영역 감지
|
||||
const currentTouchingAreas = new Set<string>();
|
||||
|
||||
if (touchState.isDragging && touchState.position) {
|
||||
for (const area of areas) {
|
||||
if (isPointInPolygon(touchState.position, area.basePoints)) {
|
||||
currentTouchingAreas.add(area.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 각 영역의 이펙트 업데이트
|
||||
for (const area of areas) {
|
||||
if (!area.spriteEffects) continue;
|
||||
|
||||
const center = getAreaCenter(area);
|
||||
|
||||
for (const effectConfig of area.spriteEffects) {
|
||||
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 ?? center);
|
||||
}
|
||||
}
|
||||
|
||||
// 매 프레임 업데이트 (ambient 방출 + 파티클 물리)
|
||||
instance.update(deltaTime, center);
|
||||
}
|
||||
}
|
||||
|
||||
// 터치 상태 갱신
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/engine/SpriteParticlePool.ts
Normal file
93
src/engine/SpriteParticlePool.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import type { Point } from '@/types';
|
||||
|
||||
/**
|
||||
* 파티클 상태 데이터
|
||||
*/
|
||||
export interface SpriteParticle {
|
||||
/** 풀 내 인덱스 */
|
||||
index: number;
|
||||
/** 활성 여부 */
|
||||
active: boolean;
|
||||
/** 정규화 좌표 위치 (0-1) */
|
||||
position: Point;
|
||||
/** 속도 (정규화 좌표/초) */
|
||||
velocity: Point;
|
||||
/** 현재 스케일 */
|
||||
scale: number;
|
||||
/** 현재 회전 (라디안) */
|
||||
rotation: number;
|
||||
/** 현재 투명도 */
|
||||
opacity: number;
|
||||
/** 경과 시간 (초) */
|
||||
age: number;
|
||||
/** 수명 (초) */
|
||||
lifetime: 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,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
age: 0,
|
||||
lifetime: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 비활성 파티클을 활성화하여 반환
|
||||
* 사용 가능한 파티클이 없으면 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;
|
||||
}
|
||||
}
|
||||
@ -93,10 +93,18 @@ export class ThreeScene {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유니폼 값 업데이트
|
||||
* @param updates 업데이트할 유니폼 값들
|
||||
|
||||
@ -225,5 +225,6 @@ export const useMouseInteraction = (
|
||||
reset,
|
||||
isDragging,
|
||||
getInteractingAreaIndices,
|
||||
getMouseState: getState,
|
||||
};
|
||||
};
|
||||
|
||||
@ -51,6 +51,14 @@ export type {
|
||||
SpringState,
|
||||
} from './types/interaction';
|
||||
|
||||
// 스프라이트 이펙트 타입
|
||||
export type {
|
||||
SpriteEffectTrigger,
|
||||
SpriteBlendMode,
|
||||
SpriteEffectConfig,
|
||||
SpriteParticleOverLifetime,
|
||||
} from './types/spriteEffect';
|
||||
|
||||
// 유틸리티 함수
|
||||
export { applyEasing } from './utils/easing';
|
||||
export { SHADER_CONFIG, ANIMATION_CONFIG, DEFAULT_AREA } from './utils/constants';
|
||||
@ -71,6 +79,7 @@ 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';
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { SpriteEffectConfig } from './spriteEffect';
|
||||
|
||||
/**
|
||||
* 정규화된 좌표계의 2D 포인트 (0.0 - 1.0)
|
||||
*/
|
||||
@ -99,6 +101,8 @@ export interface DistortionArea {
|
||||
};
|
||||
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
|
||||
snapSteps?: number;
|
||||
/** 스프라이트 이펙트 설정 배열 */
|
||||
spriteEffects?: SpriteEffectConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './area';
|
||||
export * from './shader';
|
||||
export * from './animation';
|
||||
export * from './spriteEffect';
|
||||
55
src/types/spriteEffect.ts
Normal file
55
src/types/spriteEffect.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { Point } from './area';
|
||||
|
||||
/** 이펙트 트리거 타입 */
|
||||
export type SpriteEffectTrigger = 'ambient' | 'touch';
|
||||
|
||||
/** 블렌드 모드 */
|
||||
export type SpriteBlendMode = 'normal' | 'additive';
|
||||
|
||||
/**
|
||||
* 수명 기반 파티클 속성 보간 설정
|
||||
*/
|
||||
export interface SpriteParticleOverLifetime {
|
||||
/** [시작, 끝] 스케일 */
|
||||
scale?: [number, number];
|
||||
/** [시작, 끝] 투명도 */
|
||||
opacity?: [number, number];
|
||||
/** 회전 속도 (라디안/초) */
|
||||
rotationSpeed?: number;
|
||||
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
|
||||
velocityDamping?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스프라이트 이펙트 설정
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user