feat: Add per-area physics configuration

- 영역별 물리 설정 `physics` 옵션을 추가했습니다.
- `useMouseInteraction` 훅에서 영역별 물리 설정을 적용하도록 수정했습니다.
- `ImageDistortion` 컴포넌트에서 `selectedAreaId` prop을 제거하고, 마우스 인터랙션 시 `interactingAreaIndices`를 활용하도록 변경했습니다.
- 셰이더에서 텍스처 좌표가 범위를 벗어날 때 부드럽게 페이드 아웃되도록 수정했습니다.
This commit is contained in:
BaekRyang 2025-11-24 14:23:06 +09:00
parent 61952ce79c
commit e08f34caab
11 changed files with 149 additions and 79 deletions

View File

@ -74,12 +74,21 @@ void main() {
// dragVector는 정규화된 좌표(0-1)이므로 바로 사용
vec2 distortion = u_dragVectors[i] * influence * u_distortionStrengths[i];
texCoord += distortion;
// clamp를 루프 내에서 제거: 모든 왜곡을 완전히 누적한 후 마지막에 한 번만 clamp
}
}
}
// 모든 왜곡을 누적한 후 최종적으로 한 번만 clamp
texCoord = clamp(texCoord, 0.0, 1.0);
gl_FragColor = texture2D(u_texture, texCoord);
// 경계 근처에서 부드럽게 페이드 아웃
// 텍스처 좌표가 0~1 범위를 벗어나면 알파값을 줄여서 자연스럽게 처리
vec2 edgeDist = min(texCoord, 1.0 - texCoord);
float edgeFade = smoothstep(0.0, 0.05, min(edgeDist.x, edgeDist.y));
// 범위를 벗어난 좌표는 fract로 래핑하여 반복 효과 (더 자연스러움)
vec2 wrappedCoord = fract(texCoord);
vec4 color = texture2D(u_texture, wrappedCoord);
// 경계에서 페이드 아웃 적용
color.a *= edgeFade;
gl_FragColor = color;
}

11
dist/index.d.mts vendored
View File

@ -49,6 +49,14 @@ interface DistortionArea {
progress: number;
/** 현재 드래그 벡터 (progress로부터 계산됨) */
dragVector: Point;
/** 영역별 물리 설정 (선택사항, 마우스 인터랙션 시 사용) */
physics?: {
stiffness: number;
damping: number;
mass: number;
influenceRadius: number;
maxStrength: number;
};
}
/**
*
@ -194,8 +202,6 @@ interface ImageDistortionProps {
className?: string;
/** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig;
/** 선택된 영역 ID (이 영역은 자동 애니메이션 제외) */
selectedAreaId?: string | null;
}
/**
* GPU
@ -560,6 +566,7 @@ declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement |
updateConfig: (newConfig: Partial<MouseInteractionConfig>) => void;
reset: () => void;
isDragging: () => boolean;
getInteractingAreaIndices: () => Set<number>;
};
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, DEFAULT_AREA, type DistortionArea, DistortionEditor, type DistortionEditorProps, type DistortionMovement, type EasingFunction, type EditMode, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MouseInteractionConfig, type MouseState, type Point, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, ThreeScene, applyEasing, isRotationPreset, presetToVector, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };

11
dist/index.d.ts vendored
View File

@ -49,6 +49,14 @@ interface DistortionArea {
progress: number;
/** 현재 드래그 벡터 (progress로부터 계산됨) */
dragVector: Point;
/** 영역별 물리 설정 (선택사항, 마우스 인터랙션 시 사용) */
physics?: {
stiffness: number;
damping: number;
mass: number;
influenceRadius: number;
maxStrength: number;
};
}
/**
*
@ -194,8 +202,6 @@ interface ImageDistortionProps {
className?: string;
/** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig;
/** 선택된 영역 ID (이 영역은 자동 애니메이션 제외) */
selectedAreaId?: string | null;
}
/**
* GPU
@ -560,6 +566,7 @@ declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement |
updateConfig: (newConfig: Partial<MouseInteractionConfig>) => void;
reset: () => void;
isDragging: () => boolean;
getInteractingAreaIndices: () => Set<number>;
};
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, DEFAULT_AREA, type DistortionArea, DistortionEditor, type DistortionEditorProps, type DistortionMovement, type EasingFunction, type EditMode, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MouseInteractionConfig, type MouseState, type Point, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, ThreeScene, applyEasing, isRotationPreset, presetToVector, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };

50
dist/index.js vendored
View File

@ -633,14 +633,21 @@ var useMouseInteraction = (containerRef, config) => {
const { getState } = useMouseVelocity(containerRef);
const [interactingAreaIndices, setInteractingAreaIndices] = (0, import_react3.useState)(/* @__PURE__ */ new Set());
const springPhysicsMapRef = (0, import_react3.useRef)(/* @__PURE__ */ new Map());
const getSpringPhysics = (0, import_react3.useCallback)((areaIndex) => {
const getSpringPhysics = (0, import_react3.useCallback)((areaIndex, area) => {
if (!springPhysicsMapRef.current.has(areaIndex)) {
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(config.physics));
const physicsConfig = area?.physics || config.physics;
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(physicsConfig));
}
return springPhysicsMapRef.current.get(areaIndex);
}, [config.physics]);
const updateInteraction = (0, import_react3.useCallback)((areas, deltaTime) => {
if (!config.enabled) return areas;
areas.forEach((area, index) => {
if (area.physics && springPhysicsMapRef.current.has(index)) {
const spring = springPhysicsMapRef.current.get(index);
spring.setConfig(area.physics);
}
});
const mouseState = getState();
if (mouseState.isDragging && mouseState.position) {
const currentlyInAreas = /* @__PURE__ */ new Set();
@ -648,13 +655,13 @@ var useMouseInteraction = (containerRef, config) => {
if (isPointInPolygon(mouseState.position, areas[i].basePoints)) {
currentlyInAreas.add(i);
if (!interactingAreaIndices.has(i)) {
getSpringPhysics(i).reset();
getSpringPhysics(i, areas[i]).reset();
}
}
}
interactingAreaIndices.forEach((areaIndex) => {
if (!currentlyInAreas.has(areaIndex)) {
getSpringPhysics(areaIndex).returnToEquilibrium();
getSpringPhysics(areaIndex, areas[areaIndex]).returnToEquilibrium();
}
});
setInteractingAreaIndices(currentlyInAreas);
@ -673,7 +680,7 @@ var useMouseInteraction = (containerRef, config) => {
};
}
currentlyInAreas.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex);
const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
if (velocityMag >= minVel) {
spring.setTarget(clampedVelocity, velocityMult);
} else {
@ -696,7 +703,7 @@ var useMouseInteraction = (containerRef, config) => {
};
}
interactingAreaIndices.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex);
const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
spring.setInitialVelocity(clampedVelocity, velocityMult);
});
setInteractingAreaIndices(/* @__PURE__ */ new Set());
@ -743,11 +750,15 @@ var useMouseInteraction = (containerRef, config) => {
const mouseState = getState();
return mouseState.isDragging;
}, [getState]);
const getInteractingAreaIndices = (0, import_react3.useCallback)(() => {
return interactingAreaIndices;
}, [interactingAreaIndices]);
return {
updateInteraction,
updateConfig,
reset,
isDragging
isDragging,
getInteractingAreaIndices
};
};
@ -791,8 +802,7 @@ var ImageDistortion = ({
isPlaying = true,
style,
className,
mouseInteraction,
selectedAreaId
mouseInteraction
}) => {
const containerRef = (0, import_react4.useRef)(null);
const sceneRef = (0, import_react4.useRef)(null);
@ -923,32 +933,26 @@ var ImageDistortion = ({
const animationCallback = (0, import_react4.useCallback)((deltaTime) => {
if (!isReady) return;
setCurrentAreas((prevAreas) => {
const isDragging = mouseInteractionHook.isDragging?.();
let updatedAreas = prevAreas;
if (!isDragging) {
updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
updatedAreas = updatedAreas.map((area) => {
if (selectedAreaId && area.id === selectedAreaId) {
const interactingIndices = mouseInteractionHook.getInteractingAreaIndices?.() || /* @__PURE__ */ new Set();
let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas);
if (interactingIndices.size > 0) {
updatedAreas = updatedAreas.map((area, index) => {
if (interactingIndices.has(index)) {
return {
...area,
dragVector: { x: 0, y: 0 }
};
}
const updatedArea = AnimationLoop.updateAreaDragVectors([area]);
return updatedArea[0];
return area;
});
} else {
updatedAreas = prevAreas.map((area) => ({
...area,
dragVector: { x: 0, y: 0 }
}));
}
if (mouseInteraction?.enabled) {
updatedAreas = mouseInteractionHook.updateInteraction(updatedAreas, deltaTime);
}
return updatedAreas;
});
}, [isReady, mouseInteraction, mouseInteractionHook, selectedAreaId]);
}, [isReady, mouseInteraction, mouseInteractionHook]);
useAnimationFrame(animationCallback, isPlaying || mouseInteraction?.enabled || false);
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
"div",

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

50
dist/index.mjs vendored
View File

@ -582,14 +582,21 @@ var useMouseInteraction = (containerRef, config) => {
const { getState } = useMouseVelocity(containerRef);
const [interactingAreaIndices, setInteractingAreaIndices] = useState(/* @__PURE__ */ new Set());
const springPhysicsMapRef = useRef3(/* @__PURE__ */ new Map());
const getSpringPhysics = useCallback2((areaIndex) => {
const getSpringPhysics = useCallback2((areaIndex, area) => {
if (!springPhysicsMapRef.current.has(areaIndex)) {
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(config.physics));
const physicsConfig = area?.physics || config.physics;
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(physicsConfig));
}
return springPhysicsMapRef.current.get(areaIndex);
}, [config.physics]);
const updateInteraction = useCallback2((areas, deltaTime) => {
if (!config.enabled) return areas;
areas.forEach((area, index) => {
if (area.physics && springPhysicsMapRef.current.has(index)) {
const spring = springPhysicsMapRef.current.get(index);
spring.setConfig(area.physics);
}
});
const mouseState = getState();
if (mouseState.isDragging && mouseState.position) {
const currentlyInAreas = /* @__PURE__ */ new Set();
@ -597,13 +604,13 @@ var useMouseInteraction = (containerRef, config) => {
if (isPointInPolygon(mouseState.position, areas[i].basePoints)) {
currentlyInAreas.add(i);
if (!interactingAreaIndices.has(i)) {
getSpringPhysics(i).reset();
getSpringPhysics(i, areas[i]).reset();
}
}
}
interactingAreaIndices.forEach((areaIndex) => {
if (!currentlyInAreas.has(areaIndex)) {
getSpringPhysics(areaIndex).returnToEquilibrium();
getSpringPhysics(areaIndex, areas[areaIndex]).returnToEquilibrium();
}
});
setInteractingAreaIndices(currentlyInAreas);
@ -622,7 +629,7 @@ var useMouseInteraction = (containerRef, config) => {
};
}
currentlyInAreas.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex);
const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
if (velocityMag >= minVel) {
spring.setTarget(clampedVelocity, velocityMult);
} else {
@ -645,7 +652,7 @@ var useMouseInteraction = (containerRef, config) => {
};
}
interactingAreaIndices.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex);
const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
spring.setInitialVelocity(clampedVelocity, velocityMult);
});
setInteractingAreaIndices(/* @__PURE__ */ new Set());
@ -692,11 +699,15 @@ var useMouseInteraction = (containerRef, config) => {
const mouseState = getState();
return mouseState.isDragging;
}, [getState]);
const getInteractingAreaIndices = useCallback2(() => {
return interactingAreaIndices;
}, [interactingAreaIndices]);
return {
updateInteraction,
updateConfig,
reset,
isDragging
isDragging,
getInteractingAreaIndices
};
};
@ -740,8 +751,7 @@ var ImageDistortion = ({
isPlaying = true,
style,
className,
mouseInteraction,
selectedAreaId
mouseInteraction
}) => {
const containerRef = useRef4(null);
const sceneRef = useRef4(null);
@ -872,32 +882,26 @@ var ImageDistortion = ({
const animationCallback = useCallback3((deltaTime) => {
if (!isReady) return;
setCurrentAreas((prevAreas) => {
const isDragging = mouseInteractionHook.isDragging?.();
let updatedAreas = prevAreas;
if (!isDragging) {
updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
updatedAreas = updatedAreas.map((area) => {
if (selectedAreaId && area.id === selectedAreaId) {
const interactingIndices = mouseInteractionHook.getInteractingAreaIndices?.() || /* @__PURE__ */ new Set();
let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas);
if (interactingIndices.size > 0) {
updatedAreas = updatedAreas.map((area, index) => {
if (interactingIndices.has(index)) {
return {
...area,
dragVector: { x: 0, y: 0 }
};
}
const updatedArea = AnimationLoop.updateAreaDragVectors([area]);
return updatedArea[0];
return area;
});
} else {
updatedAreas = prevAreas.map((area) => ({
...area,
dragVector: { x: 0, y: 0 }
}));
}
if (mouseInteraction?.enabled) {
updatedAreas = mouseInteractionHook.updateInteraction(updatedAreas, deltaTime);
}
return updatedAreas;
});
}, [isReady, mouseInteraction, mouseInteractionHook, selectedAreaId]);
}, [isReady, mouseInteraction, mouseInteractionHook]);
useAnimationFrame(animationCallback, isPlaying || mouseInteraction?.enabled || false);
return /* @__PURE__ */ jsx(
"div",

2
dist/index.mjs.map vendored

File diff suppressed because one or more lines are too long

View File

@ -214,20 +214,24 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
if (!isReady) return;
setCurrentAreas((prevAreas) => {
// 마우스가 드래그 중인지 확인
const isDragging = mouseInteractionHook.isDragging?.();
// 현재 인터랙션 중인 영역 인덱스 가져오기
const interactingIndices = mouseInteractionHook.getInteractingAreaIndices?.() || new Set<number>();
// 1. 자동 애니메이션 업데이트 (마우스 드래그 중이 아닐 때만)
let updatedAreas = prevAreas;
if (!isDragging) {
updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
// 1. 자동 애니메이션 업데이트
let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas);
} else {
// 드래그 중일 때는 자동 애니메이션 dragVector를 0으로 설정
updatedAreas = prevAreas.map(area => ({
// 인터랙션 중인 영역만 dragVector를 0으로 설정
if (interactingIndices.size > 0) {
updatedAreas = updatedAreas.map((area, index) => {
if (interactingIndices.has(index)) {
return {
...area,
dragVector: { x: 0, y: 0 }
}));
};
}
return area;
});
}
// 2. 마우스 인터랙션 적용 (기존 dragVector에 스프링 변위 추가)

View File

@ -34,9 +34,11 @@ export const useMouseInteraction = (
/**
* ( )
*/
const getSpringPhysics = useCallback((areaIndex: number): SpringPhysics => {
const getSpringPhysics = useCallback((areaIndex: number, area?: DistortionArea): SpringPhysics => {
if (!springPhysicsMapRef.current.has(areaIndex)) {
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(config.physics));
// 영역별 물리 설정이 있으면 사용, 없으면 전역 설정 사용
const physicsConfig = area?.physics || config.physics;
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(physicsConfig));
}
return springPhysicsMapRef.current.get(areaIndex)!;
}, [config.physics]);
@ -47,6 +49,14 @@ export const useMouseInteraction = (
const updateInteraction = useCallback((areas: DistortionArea[], deltaTime: number): DistortionArea[] => {
if (!config.enabled) return areas;
// 영역별 물리 설정이 변경되었을 수 있으므로 모든 스프링 업데이트
areas.forEach((area, index) => {
if (area.physics && springPhysicsMapRef.current.has(index)) {
const spring = springPhysicsMapRef.current.get(index)!;
spring.setConfig(area.physics);
}
});
const mouseState = getState();
// 마우스 클릭/드래그 중이고 위치가 있으면
@ -59,7 +69,7 @@ export const useMouseInteraction = (
// 새로 진입한 영역이면 스프링 리셋
if (!interactingAreaIndices.has(i)) {
getSpringPhysics(i).reset();
getSpringPhysics(i, areas[i]).reset();
}
}
}
@ -67,7 +77,7 @@ export const useMouseInteraction = (
// 이전에 인터랙션하던 영역에서 벗어났으면 평형으로 복귀
interactingAreaIndices.forEach((areaIndex) => {
if (!currentlyInAreas.has(areaIndex)) {
getSpringPhysics(areaIndex).returnToEquilibrium();
getSpringPhysics(areaIndex, areas[areaIndex]).returnToEquilibrium();
}
});
@ -93,7 +103,7 @@ export const useMouseInteraction = (
}
currentlyInAreas.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex);
const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
if (velocityMag >= minVel) {
// 드래그 중: 마우스 속도를 목표로 설정
@ -124,7 +134,7 @@ export const useMouseInteraction = (
// 모든 인터랙션 영역에 초기 속도 설정
interactingAreaIndices.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex);
const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
spring.setInitialVelocity(clampedVelocity, velocityMult);
});
@ -202,10 +212,18 @@ export const useMouseInteraction = (
return mouseState.isDragging;
}, [getState]);
/**
*
*/
const getInteractingAreaIndices = useCallback((): Set<number> => {
return interactingAreaIndices;
}, [interactingAreaIndices]);
return {
updateInteraction,
updateConfig,
reset,
isDragging,
getInteractingAreaIndices,
};
};

View File

@ -74,12 +74,21 @@ void main() {
// dragVector는 정규화된 좌표(0-1)이므로 바로 사용
vec2 distortion = u_dragVectors[i] * influence * u_distortionStrengths[i];
texCoord += distortion;
// clamp를 루프 내에서 제거: 모든 왜곡을 완전히 누적한 후 마지막에 한 번만 clamp
}
}
}
// 모든 왜곡을 누적한 후 최종적으로 한 번만 clamp
texCoord = clamp(texCoord, 0.0, 1.0);
gl_FragColor = texture2D(u_texture, texCoord);
// 경계 근처에서 부드럽게 페이드 아웃
// 텍스처 좌표가 0~1 범위를 벗어나면 알파값을 줄여서 자연스럽게 처리
vec2 edgeDist = min(texCoord, 1.0 - texCoord);
float edgeFade = smoothstep(0.0, 0.05, min(edgeDist.x, edgeDist.y));
// 범위를 벗어난 좌표는 fract로 래핑하여 반복 효과 (더 자연스러움)
vec2 wrappedCoord = fract(texCoord);
vec4 color = texture2D(u_texture, wrappedCoord);
// 경계에서 페이드 아웃 적용
color.a *= edgeFade;
gl_FragColor = color;
}

View File

@ -64,6 +64,14 @@ export interface DistortionArea {
progress: number;
/** 현재 드래그 벡터 (progress로부터 계산됨) */
dragVector: Point;
/** 영역별 물리 설정 (선택사항, 마우스 인터랙션 시 사용) */
physics?: {
stiffness: number;
damping: number;
mass: number;
influenceRadius: number;
maxStrength: number;
};
}
/**