Add sprite particle effects and improve lens distortion

- 스프라이트 기반 파티클 이펙트 관리 기능 추가 (SpriteEffectManager)
- 렌즈 왜곡 셰이더 로직 개선 및 픽셀 공간 기준 등방성 확대 적용
- 파티클 최적화를 위한 오브젝트 풀링(SpriteParticlePool) 도입
- DistortionArea 타입에 spriteEffects 설정 필드 추가
- ThreeScene에 씬 객체 접근 기능 및 렌더 순서 제어 추가
- useMouseInteraction 훅에서 마우스 상태 조회 기능 추가
- 버전 1.3.0 업데이트 및 관련 타입 정의 반영
This commit is contained in:
BaekRyang 2026-03-10 13:24:12 +09:00
parent c72846b06e
commit 48fdd5e17c
19 changed files with 1667 additions and 32 deletions

View File

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

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

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

@ -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

File diff suppressed because one or more lines are too long

410
dist/index.mjs vendored
View File

@ -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

File diff suppressed because one or more lines are too long

4
package-lock.json generated
View File

@ -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",

View File

@ -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/"
},

View File

@ -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]);
// 애니메이션 루프 실행

View 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]);
}
}
}

View 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);
}
}
}

View 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;
}
}

View File

@ -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

View File

@ -225,5 +225,6 @@ export const useMouseInteraction = (
reset,
isDragging,
getInteractingAreaIndices,
getMouseState: getState,
};
};

View File

@ -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';

View File

@ -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[];
}
/**

View File

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

55
src/types/spriteEffect.ts Normal file
View 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;
}