Compare commits
3 Commits
09cb2f09af
...
0ffa83df88
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ffa83df88 | |||
| 692f9b1260 | |||
| 8188d8d79e |
17
dist/index.d.mts
vendored
17
dist/index.d.mts
vendored
@ -143,8 +143,13 @@ interface AnimationTicker {
|
||||
resume: () => void;
|
||||
}
|
||||
|
||||
/** 이펙트 트리거 타입 */
|
||||
type SpriteEffectTrigger = 'ambient' | 'touch';
|
||||
/**
|
||||
* 이펙트 트리거 타입
|
||||
* - ambient: 영역 중심에서 계속 방출
|
||||
* - touch: 영역에 새로 닿는 순간 한 번 터짐
|
||||
* - hold: 포인터가 영역 안에 머무는 동안 포인터 자리에서 계속 방출 (트레일)
|
||||
*/
|
||||
type SpriteEffectTrigger = 'ambient' | 'touch' | 'hold';
|
||||
/** 블렌드 모드 */
|
||||
type SpriteBlendMode = 'normal' | 'additive';
|
||||
/**
|
||||
@ -215,6 +220,8 @@ interface SpriteEffectAreaData {
|
||||
initialScale: [number, number];
|
||||
initialSpeed: [number, number];
|
||||
emitAngle?: [number, number];
|
||||
/** [최소, 최대] spawn 시 회전 각도 (도) */
|
||||
initialRotation?: [number, number];
|
||||
emitOffset?: {
|
||||
x: number;
|
||||
y: number;
|
||||
@ -240,7 +247,7 @@ interface SpriteEffectConfig {
|
||||
blendMode?: SpriteBlendMode;
|
||||
/** 최대 파티클 수 */
|
||||
maxParticles: number;
|
||||
/** ambient: 초당 방출 수 */
|
||||
/** ambient·hold: 초당 방출 수 */
|
||||
emitRate?: number;
|
||||
/** touch: 터치 시 방출 수 */
|
||||
burstCount?: number;
|
||||
@ -252,6 +259,8 @@ interface SpriteEffectConfig {
|
||||
initialSpeed: [number, number];
|
||||
/** 방출 각도 범위 (도) */
|
||||
emitAngle?: [number, number];
|
||||
/** [최소, 최대] spawn 시 회전 각도 (도) - 파티클마다 이 범위에서 뽑아 그만큼 돌린 채로 나온다 */
|
||||
initialRotation?: [number, number];
|
||||
/** 영역 중심 대비 방출 오프셋 */
|
||||
emitOffset?: Point;
|
||||
/** 방출 범위 반경 */
|
||||
@ -341,6 +350,8 @@ interface ImageDistortionProps {
|
||||
mouseInteraction?: MouseInteractionConfig;
|
||||
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
|
||||
spriteEffectAreas?: SpriteEffectArea[];
|
||||
/** 첫 텍스처 렌더가 화면에 그려진 직후 1회 호출 - 로딩 스켈레톤 내리는 타이밍용 */
|
||||
onFirstRender?: () => void;
|
||||
}
|
||||
/**
|
||||
* GPU 가속 이미지 왜곡 컴포넌트
|
||||
|
||||
17
dist/index.d.ts
vendored
17
dist/index.d.ts
vendored
@ -143,8 +143,13 @@ interface AnimationTicker {
|
||||
resume: () => void;
|
||||
}
|
||||
|
||||
/** 이펙트 트리거 타입 */
|
||||
type SpriteEffectTrigger = 'ambient' | 'touch';
|
||||
/**
|
||||
* 이펙트 트리거 타입
|
||||
* - ambient: 영역 중심에서 계속 방출
|
||||
* - touch: 영역에 새로 닿는 순간 한 번 터짐
|
||||
* - hold: 포인터가 영역 안에 머무는 동안 포인터 자리에서 계속 방출 (트레일)
|
||||
*/
|
||||
type SpriteEffectTrigger = 'ambient' | 'touch' | 'hold';
|
||||
/** 블렌드 모드 */
|
||||
type SpriteBlendMode = 'normal' | 'additive';
|
||||
/**
|
||||
@ -215,6 +220,8 @@ interface SpriteEffectAreaData {
|
||||
initialScale: [number, number];
|
||||
initialSpeed: [number, number];
|
||||
emitAngle?: [number, number];
|
||||
/** [최소, 최대] spawn 시 회전 각도 (도) */
|
||||
initialRotation?: [number, number];
|
||||
emitOffset?: {
|
||||
x: number;
|
||||
y: number;
|
||||
@ -240,7 +247,7 @@ interface SpriteEffectConfig {
|
||||
blendMode?: SpriteBlendMode;
|
||||
/** 최대 파티클 수 */
|
||||
maxParticles: number;
|
||||
/** ambient: 초당 방출 수 */
|
||||
/** ambient·hold: 초당 방출 수 */
|
||||
emitRate?: number;
|
||||
/** touch: 터치 시 방출 수 */
|
||||
burstCount?: number;
|
||||
@ -252,6 +259,8 @@ interface SpriteEffectConfig {
|
||||
initialSpeed: [number, number];
|
||||
/** 방출 각도 범위 (도) */
|
||||
emitAngle?: [number, number];
|
||||
/** [최소, 최대] spawn 시 회전 각도 (도) - 파티클마다 이 범위에서 뽑아 그만큼 돌린 채로 나온다 */
|
||||
initialRotation?: [number, number];
|
||||
/** 영역 중심 대비 방출 오프셋 */
|
||||
emitOffset?: Point;
|
||||
/** 방출 범위 반경 */
|
||||
@ -341,6 +350,8 @@ interface ImageDistortionProps {
|
||||
mouseInteraction?: MouseInteractionConfig;
|
||||
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
|
||||
spriteEffectAreas?: SpriteEffectArea[];
|
||||
/** 첫 텍스처 렌더가 화면에 그려진 직후 1회 호출 - 로딩 스켈레톤 내리는 타이밍용 */
|
||||
onFirstRender?: () => void;
|
||||
}
|
||||
/**
|
||||
* GPU 가속 이미지 왜곡 컴포넌트
|
||||
|
||||
235
dist/index.js
vendored
235
dist/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/index.js.map
vendored
2
dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
235
dist/index.mjs
vendored
235
dist/index.mjs
vendored
File diff suppressed because one or more lines are too long
2
dist/index.mjs.map
vendored
2
dist/index.mjs.map
vendored
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@baekryang/responsive-image-canvas",
|
||||
"version": "1.5.5",
|
||||
"version": "1.8.0",
|
||||
"publishConfig": {
|
||||
"registry": "https://git.bnovalab.com/api/packages/baekryang/npm/"
|
||||
},
|
||||
@ -19,8 +19,8 @@
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format cjs,esm --dts",
|
||||
"dev": "tsup src/index.ts --format cjs,esm --dts --watch"
|
||||
"build": "tsup src/index.ts --format cjs,esm --dts --loader \".glsl=text\"",
|
||||
"dev": "tsup src/index.ts --format cjs,esm --dts --watch --loader \".glsl=text\""
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
|
||||
@ -2,14 +2,23 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import {type DistortionArea, SpriteEffectArea} from '@/types';
|
||||
import { ThreeScene } from '@/engine/ThreeScene';
|
||||
import { ShaderManager } from '@/engine/ShaderManager';
|
||||
import { AnimationLoop } from '@/engine/AnimationLoop';
|
||||
import { SpriteEffectManager } from '@/engine/SpriteEffectManager';
|
||||
import { useAnimationFrame } from '@/hooks/useAnimationFrame';
|
||||
import { useMouseInteraction } from '@/hooks/useMouseInteraction';
|
||||
import { SHADER_CONFIG } from '@/utils/constants';
|
||||
import { describeSrc } from '@/utils/describeSrc';
|
||||
import { MouseInteractionConfig } from '@/types/interaction';
|
||||
import bundledVertexShader from '@/shaders/distortion.vert.glsl';
|
||||
import bundledFragmentShader from '@/shaders/distortion.frag.glsl';
|
||||
|
||||
/** 커스텀 셰이더 경로가 주어졌을 때만 쓰는 fetch 로더 */
|
||||
async function fetchShaderSource(path: string): Promise<string> {
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) {
|
||||
throw new Error(`셰이더 로드 실패 (${path}): ${response.statusText}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageDistortion 컴포넌트 Props
|
||||
@ -31,6 +40,8 @@ export interface ImageDistortionProps {
|
||||
mouseInteraction?: MouseInteractionConfig;
|
||||
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
|
||||
spriteEffectAreas?: SpriteEffectArea[];
|
||||
/** 첫 텍스처 렌더가 화면에 그려진 직후 1회 호출 - 로딩 스켈레톤 내리는 타이밍용 */
|
||||
onFirstRender?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -46,11 +57,15 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
||||
className,
|
||||
mouseInteraction,
|
||||
spriteEffectAreas = [],
|
||||
onFirstRender,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sceneRef = useRef<ThreeScene | null>(null);
|
||||
const shaderManagerRef = useRef<ShaderManager>(new ShaderManager());
|
||||
const textureRef = useRef<THREE.Texture | null>(null);
|
||||
// 콜백을 ref로 들고 있어 렌더마다 바뀌어도 로드 effect가 재실행되지 않게 한다
|
||||
const onFirstRenderRef = useRef(onFirstRender);
|
||||
onFirstRenderRef.current = onFirstRender;
|
||||
const firstRenderNotifiedRef = useRef(false);
|
||||
const spriteManagerRef = useRef<SpriteEffectManager | null>(null);
|
||||
const currentAreasRef = useRef<DistortionArea[]>(areas);
|
||||
|
||||
@ -109,35 +124,35 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
||||
|
||||
// Three.js 씬 초기화
|
||||
useEffect(() => {
|
||||
console.log('[ImageDistortion] useEffect 실행, containerRef.current:', containerRef.current);
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (!containerRef.current) {
|
||||
console.warn('[ImageDistortion] containerRef.current가 null입니다. 컴포넌트가 제대로 마운트되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ImageDistortion] v1.5.1 초기화 시작');
|
||||
const scene = new ThreeScene(containerRef.current);
|
||||
sceneRef.current = scene;
|
||||
|
||||
// 셰이더 로드
|
||||
const vertPath = vertexShaderPath || '/shaders/distortion.vert.glsl';
|
||||
const fragPath = fragmentShaderPath || '/shaders/distortion.frag.glsl';
|
||||
let cancelled = false;
|
||||
const apply = (vertex: string, fragment: string) => {
|
||||
if (cancelled) return;
|
||||
scene.setShaderMaterial(vertex, fragment);
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
console.log('[ImageDistortion] 셰이더 로드 시도:', { vertPath, fragPath });
|
||||
|
||||
shaderManagerRef.current
|
||||
.loadShaders(vertPath, fragPath)
|
||||
.then(({ vertex, fragment }) => {
|
||||
console.log('[ImageDistortion] 셰이더 로드 성공');
|
||||
scene.setShaderMaterial(vertex, fragment);
|
||||
setIsReady(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[ImageDistortion] 셰이더 로드 실패:', error);
|
||||
});
|
||||
if (!vertexShaderPath && !fragmentShaderPath) {
|
||||
// 번들된 셰이더 - 네트워크 왕복 없이 즉시 준비된다
|
||||
apply(bundledVertexShader, bundledFragmentShader);
|
||||
} else {
|
||||
// 커스텀 경로가 주어진 것만 fetch, 나머지는 번들 셰이더
|
||||
Promise.all([
|
||||
vertexShaderPath ? fetchShaderSource(vertexShaderPath) : Promise.resolve(bundledVertexShader),
|
||||
fragmentShaderPath ? fetchShaderSource(fragmentShaderPath) : Promise.resolve(bundledFragmentShader),
|
||||
])
|
||||
.then(([vertex, fragment]) => apply(vertex, fragment))
|
||||
.catch((error) => {
|
||||
console.error('[ImageDistortion] 셰이더 로드 실패:', error);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
scene.dispose();
|
||||
if (textureRef.current) {
|
||||
textureRef.current.dispose();
|
||||
@ -147,27 +162,14 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
||||
|
||||
// 이미지 텍스처 로드
|
||||
useEffect(() => {
|
||||
if (!imageSrc || !isReady) {
|
||||
console.log('[ImageDistortion] 이미지 로드 스킵:', {
|
||||
imageSrc: describeSrc(imageSrc),
|
||||
isReady,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!imageSrc || !isReady) return;
|
||||
|
||||
// describeSrc: data: URL을 그대로 찍으면 DevTools가 그 한 줄을 렌더하느라
|
||||
// 메인 스레드가 수 초간 멈춘다. 너무 길면 길이만 남기고 본문은 버린다.
|
||||
console.log('[ImageDistortion] 이미지 로드 시작:', describeSrc(imageSrc));
|
||||
setImageLoaded(false);
|
||||
|
||||
const loader = new THREE.TextureLoader();
|
||||
loader.load(
|
||||
imageSrc,
|
||||
(texture) => {
|
||||
console.log('[ImageDistortion] 이미지 로드 성공!', {
|
||||
width: texture.image.width,
|
||||
height: texture.image.height
|
||||
});
|
||||
textureRef.current = texture;
|
||||
setImageLoaded(true);
|
||||
if (sceneRef.current) {
|
||||
@ -175,14 +177,13 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
||||
u_texture: { value: texture },
|
||||
});
|
||||
sceneRef.current.render();
|
||||
console.log('[ImageDistortion] 텍스처 업데이트 및 렌더링 완료');
|
||||
if (!firstRenderNotifiedRef.current) {
|
||||
firstRenderNotifiedRef.current = true;
|
||||
onFirstRenderRef.current?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
(progress) => {
|
||||
console.log('[ImageDistortion] 이미지 로딩 중...',
|
||||
Math.round((progress.loaded / progress.total) * 100) + '%'
|
||||
);
|
||||
},
|
||||
undefined,
|
||||
(error) => {
|
||||
console.error('[ImageDistortion] 이미지 로드 실패:', error);
|
||||
setImageLoaded(false);
|
||||
|
||||
@ -199,7 +199,10 @@ export class SpriteEffectInstance {
|
||||
// 초기 속성
|
||||
particle.initialScale = randomRange(config.initialScale[0], config.initialScale[1]);
|
||||
particle.scale = particle.initialScale;
|
||||
particle.rotation = 0;
|
||||
const rotationRange = config.initialRotation;
|
||||
particle.rotation = rotationRange
|
||||
? (randomRange(rotationRange[0], rotationRange[1]) * Math.PI) / 180
|
||||
: 0;
|
||||
particle.opacity = 1;
|
||||
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
|
||||
particle.age = 0;
|
||||
@ -237,14 +240,17 @@ export class SpriteEffectInstance {
|
||||
* 매 프레임 업데이트
|
||||
* @param deltaTime 초 단위 프레임 시간
|
||||
* @param emitCenter 방출 중심 (정규화 좌표 0-1)
|
||||
* @param holdActive hold 이펙트에서 포인터가 영역 안에 있는지 - 있는 동안만 방출한다
|
||||
*/
|
||||
private _logCounter = 0;
|
||||
|
||||
update(deltaTime: number, emitCenter: Point, resolution?: { x: number; y: number }): void {
|
||||
update(deltaTime: number, emitCenter: Point, resolution?: { x: number; y: number }, holdActive = false): void {
|
||||
if (!this.ready) return;
|
||||
|
||||
// ambient 방출
|
||||
if (this.config.trigger === 'ambient') {
|
||||
// 지속 방출 - ambient는 항상, hold는 포인터가 머무는 동안만
|
||||
const emitting = this.config.trigger === 'ambient'
|
||||
|| (this.config.trigger === 'hold' && holdActive);
|
||||
if (emitting) {
|
||||
this.updateAmbientEmit(deltaTime, emitCenter);
|
||||
}
|
||||
|
||||
|
||||
@ -94,13 +94,15 @@ export class SpriteEffectManager {
|
||||
update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState, resolution?: { x: number; y: number }): void {
|
||||
// 현재 터치 중인 영역 감지
|
||||
const currentTouchingAreas = new Set<string>();
|
||||
// 포인터가 머물고 있는 영역 - 마우스는 올려두기만 해도, 터치는 누르고 있는 동안 들어온다
|
||||
const currentHoveringAreas = new Set<string>();
|
||||
|
||||
if (touchState.isDragging && touchState.position) {
|
||||
if (touchState.position) {
|
||||
for (const area of effectAreas) {
|
||||
const radius = area.radius ?? 0.1;
|
||||
if (isPointInCircle(touchState.position, area.position, radius, resolution)) {
|
||||
currentTouchingAreas.add(area.id);
|
||||
}
|
||||
if (!isPointInCircle(touchState.position, area.position, radius, resolution)) continue;
|
||||
currentHoveringAreas.add(area.id);
|
||||
if (touchState.isDragging) currentTouchingAreas.add(area.id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,8 +122,15 @@ export class SpriteEffectManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 매 프레임 업데이트 (ambient 방출 + 파티클 물리)
|
||||
instance.update(deltaTime, area.position, resolution);
|
||||
// hold 이펙트: 포인터가 머무는 동안 그 자리에서 계속 방출 - 트레일처럼 따라온다
|
||||
const isHolding = effectConfig.trigger === 'hold'
|
||||
&& currentHoveringAreas.has(area.id);
|
||||
const emitCenter = isHolding && touchState.position
|
||||
? touchState.position
|
||||
: area.position;
|
||||
|
||||
// 매 프레임 업데이트 (지속 방출 + 파티클 물리)
|
||||
instance.update(deltaTime, emitCenter, resolution, isHolding);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
5
src/glsl.d.ts
vendored
Normal file
5
src/glsl.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
// .glsl 임포트를 문자열로 취급 (tsup --loader .glsl=text와 한 벌)
|
||||
declare module '*.glsl' {
|
||||
const source: string;
|
||||
export default source;
|
||||
}
|
||||
@ -1,7 +1,12 @@
|
||||
import type { Point } from './area';
|
||||
|
||||
/** 이펙트 트리거 타입 */
|
||||
export type SpriteEffectTrigger = 'ambient' | 'touch';
|
||||
/**
|
||||
* 이펙트 트리거 타입
|
||||
* - ambient: 영역 중심에서 계속 방출
|
||||
* - touch: 영역에 새로 닿는 순간 한 번 터짐
|
||||
* - hold: 포인터가 영역 안에 머무는 동안 포인터 자리에서 계속 방출 (트레일)
|
||||
*/
|
||||
export type SpriteEffectTrigger = 'ambient' | 'touch' | 'hold';
|
||||
|
||||
/** 블렌드 모드 */
|
||||
export type SpriteBlendMode = 'normal' | 'additive';
|
||||
@ -74,6 +79,8 @@ export interface SpriteEffectAreaData {
|
||||
initialScale: [number, number];
|
||||
initialSpeed: [number, number];
|
||||
emitAngle?: [number, number];
|
||||
/** [최소, 최대] spawn 시 회전 각도 (도) */
|
||||
initialRotation?: [number, number];
|
||||
emitOffset?: { x: number; y: number };
|
||||
emitRadius?: number;
|
||||
overLifetime?: SpriteParticleOverLifetime;
|
||||
@ -97,7 +104,7 @@ export interface SpriteEffectConfig {
|
||||
blendMode?: SpriteBlendMode;
|
||||
/** 최대 파티클 수 */
|
||||
maxParticles: number;
|
||||
/** ambient: 초당 방출 수 */
|
||||
/** ambient·hold: 초당 방출 수 */
|
||||
emitRate?: number;
|
||||
/** touch: 터치 시 방출 수 */
|
||||
burstCount?: number;
|
||||
@ -109,6 +116,8 @@ export interface SpriteEffectConfig {
|
||||
initialSpeed: [number, number];
|
||||
/** 방출 각도 범위 (도) */
|
||||
emitAngle?: [number, number];
|
||||
/** [최소, 최대] spawn 시 회전 각도 (도) - 파티클마다 이 범위에서 뽑아 그만큼 돌린 채로 나온다 */
|
||||
initialRotation?: [number, number];
|
||||
/** 영역 중심 대비 방출 오프셋 */
|
||||
emitOffset?: Point;
|
||||
/** 방출 범위 반경 */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user