Compare commits

..

No commits in common. "0ffa83df88285071584136ba87cc146dc6517d78" and "09cb2f09af51e87be9954304b714b2efea439231" have entirely different histories.

12 changed files with 301 additions and 359 deletions

17
dist/index.d.mts vendored
View File

@ -143,13 +143,8 @@ interface AnimationTicker {
resume: () => void; resume: () => void;
} }
/** /** 이펙트 트리거 타입 */
* type SpriteEffectTrigger = 'ambient' | 'touch';
* - ambient: 영역
* - touch: 영역에
* - hold: 포인터가 ()
*/
type SpriteEffectTrigger = 'ambient' | 'touch' | 'hold';
/** 블렌드 모드 */ /** 블렌드 모드 */
type SpriteBlendMode = 'normal' | 'additive'; type SpriteBlendMode = 'normal' | 'additive';
/** /**
@ -220,8 +215,6 @@ interface SpriteEffectAreaData {
initialScale: [number, number]; initialScale: [number, number];
initialSpeed: [number, number]; initialSpeed: [number, number];
emitAngle?: [number, number]; emitAngle?: [number, number];
/** [최소, 최대] spawn 시 회전 각도 (도) */
initialRotation?: [number, number];
emitOffset?: { emitOffset?: {
x: number; x: number;
y: number; y: number;
@ -247,7 +240,7 @@ interface SpriteEffectConfig {
blendMode?: SpriteBlendMode; blendMode?: SpriteBlendMode;
/** 최대 파티클 수 */ /** 최대 파티클 수 */
maxParticles: number; maxParticles: number;
/** ambient·hold: 초당 방출 수 */ /** ambient: 초당 방출 수 */
emitRate?: number; emitRate?: number;
/** touch: 터치 시 방출 수 */ /** touch: 터치 시 방출 수 */
burstCount?: number; burstCount?: number;
@ -259,8 +252,6 @@ interface SpriteEffectConfig {
initialSpeed: [number, number]; initialSpeed: [number, number];
/** 방출 각도 범위 (도) */ /** 방출 각도 범위 (도) */
emitAngle?: [number, number]; emitAngle?: [number, number];
/** [최소, 최대] spawn 시 회전 각도 (도) - 파티클마다 이 범위에서 뽑아 그만큼 돌린 채로 나온다 */
initialRotation?: [number, number];
/** 영역 중심 대비 방출 오프셋 */ /** 영역 중심 대비 방출 오프셋 */
emitOffset?: Point; emitOffset?: Point;
/** 방출 범위 반경 */ /** 방출 범위 반경 */
@ -350,8 +341,6 @@ interface ImageDistortionProps {
mouseInteraction?: MouseInteractionConfig; mouseInteraction?: MouseInteractionConfig;
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */ /** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
spriteEffectAreas?: SpriteEffectArea[]; spriteEffectAreas?: SpriteEffectArea[];
/** 첫 텍스처 렌더가 화면에 그려진 직후 1회 호출 - 로딩 스켈레톤 내리는 타이밍용 */
onFirstRender?: () => void;
} }
/** /**
* GPU * GPU

17
dist/index.d.ts vendored
View File

@ -143,13 +143,8 @@ interface AnimationTicker {
resume: () => void; resume: () => void;
} }
/** /** 이펙트 트리거 타입 */
* type SpriteEffectTrigger = 'ambient' | 'touch';
* - ambient: 영역
* - touch: 영역에
* - hold: 포인터가 ()
*/
type SpriteEffectTrigger = 'ambient' | 'touch' | 'hold';
/** 블렌드 모드 */ /** 블렌드 모드 */
type SpriteBlendMode = 'normal' | 'additive'; type SpriteBlendMode = 'normal' | 'additive';
/** /**
@ -220,8 +215,6 @@ interface SpriteEffectAreaData {
initialScale: [number, number]; initialScale: [number, number];
initialSpeed: [number, number]; initialSpeed: [number, number];
emitAngle?: [number, number]; emitAngle?: [number, number];
/** [최소, 최대] spawn 시 회전 각도 (도) */
initialRotation?: [number, number];
emitOffset?: { emitOffset?: {
x: number; x: number;
y: number; y: number;
@ -247,7 +240,7 @@ interface SpriteEffectConfig {
blendMode?: SpriteBlendMode; blendMode?: SpriteBlendMode;
/** 최대 파티클 수 */ /** 최대 파티클 수 */
maxParticles: number; maxParticles: number;
/** ambient·hold: 초당 방출 수 */ /** ambient: 초당 방출 수 */
emitRate?: number; emitRate?: number;
/** touch: 터치 시 방출 수 */ /** touch: 터치 시 방출 수 */
burstCount?: number; burstCount?: number;
@ -259,8 +252,6 @@ interface SpriteEffectConfig {
initialSpeed: [number, number]; initialSpeed: [number, number];
/** 방출 각도 범위 (도) */ /** 방출 각도 범위 (도) */
emitAngle?: [number, number]; emitAngle?: [number, number];
/** [최소, 최대] spawn 시 회전 각도 (도) - 파티클마다 이 범위에서 뽑아 그만큼 돌린 채로 나온다 */
initialRotation?: [number, number];
/** 영역 중심 대비 방출 오프셋 */ /** 영역 중심 대비 방출 오프셋 */
emitOffset?: Point; emitOffset?: Point;
/** 방출 범위 반경 */ /** 방출 범위 반경 */
@ -350,8 +341,6 @@ interface ImageDistortionProps {
mouseInteraction?: MouseInteractionConfig; mouseInteraction?: MouseInteractionConfig;
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */ /** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
spriteEffectAreas?: SpriteEffectArea[]; spriteEffectAreas?: SpriteEffectArea[];
/** 첫 텍스처 렌더가 화면에 그려진 직후 1회 호출 - 로딩 스켈레톤 내리는 타이밍용 */
onFirstRender?: () => void;
} }
/** /**
* GPU * GPU

235
dist/index.js vendored

File diff suppressed because one or more lines are too long

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

235
dist/index.mjs vendored

File diff suppressed because one or more lines are too long

2
dist/index.mjs.map vendored

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{ {
"name": "@baekryang/responsive-image-canvas", "name": "@baekryang/responsive-image-canvas",
"version": "1.8.0", "version": "1.5.5",
"publishConfig": { "publishConfig": {
"registry": "https://git.bnovalab.com/api/packages/baekryang/npm/" "registry": "https://git.bnovalab.com/api/packages/baekryang/npm/"
}, },
@ -19,8 +19,8 @@
"dist" "dist"
], ],
"scripts": { "scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --loader \".glsl=text\"", "build": "tsup src/index.ts --format cjs,esm --dts",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch --loader \".glsl=text\"" "dev": "tsup src/index.ts --format cjs,esm --dts --watch"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0",

View File

@ -2,23 +2,14 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as THREE from 'three'; import * as THREE from 'three';
import {type DistortionArea, SpriteEffectArea} from '@/types'; import {type DistortionArea, SpriteEffectArea} from '@/types';
import { ThreeScene } from '@/engine/ThreeScene'; import { ThreeScene } from '@/engine/ThreeScene';
import { ShaderManager } from '@/engine/ShaderManager';
import { AnimationLoop } from '@/engine/AnimationLoop'; import { AnimationLoop } from '@/engine/AnimationLoop';
import { SpriteEffectManager } from '@/engine/SpriteEffectManager'; import { SpriteEffectManager } from '@/engine/SpriteEffectManager';
import { useAnimationFrame } from '@/hooks/useAnimationFrame'; import { useAnimationFrame } from '@/hooks/useAnimationFrame';
import { useMouseInteraction } from '@/hooks/useMouseInteraction'; import { useMouseInteraction } from '@/hooks/useMouseInteraction';
import { SHADER_CONFIG } from '@/utils/constants'; import { SHADER_CONFIG } from '@/utils/constants';
import { describeSrc } from '@/utils/describeSrc';
import { MouseInteractionConfig } from '@/types/interaction'; 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 * ImageDistortion Props
@ -40,8 +31,6 @@ export interface ImageDistortionProps {
mouseInteraction?: MouseInteractionConfig; mouseInteraction?: MouseInteractionConfig;
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */ /** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
spriteEffectAreas?: SpriteEffectArea[]; spriteEffectAreas?: SpriteEffectArea[];
/** 첫 텍스처 렌더가 화면에 그려진 직후 1회 호출 - 로딩 스켈레톤 내리는 타이밍용 */
onFirstRender?: () => void;
} }
/** /**
@ -57,15 +46,11 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
className, className,
mouseInteraction, mouseInteraction,
spriteEffectAreas = [], spriteEffectAreas = [],
onFirstRender,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<ThreeScene | null>(null); const sceneRef = useRef<ThreeScene | null>(null);
const shaderManagerRef = useRef<ShaderManager>(new ShaderManager());
const textureRef = useRef<THREE.Texture | null>(null); 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 spriteManagerRef = useRef<SpriteEffectManager | null>(null);
const currentAreasRef = useRef<DistortionArea[]>(areas); const currentAreasRef = useRef<DistortionArea[]>(areas);
@ -124,35 +109,35 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
// Three.js 씬 초기화 // Three.js 씬 초기화
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; console.log('[ImageDistortion] useEffect 실행, containerRef.current:', containerRef.current);
if (!containerRef.current) {
console.warn('[ImageDistortion] containerRef.current가 null입니다. 컴포넌트가 제대로 마운트되지 않았습니다.');
return;
}
console.log('[ImageDistortion] v1.5.1 초기화 시작');
const scene = new ThreeScene(containerRef.current); const scene = new ThreeScene(containerRef.current);
sceneRef.current = scene; sceneRef.current = scene;
let cancelled = false; // 셰이더 로드
const apply = (vertex: string, fragment: string) => { const vertPath = vertexShaderPath || '/shaders/distortion.vert.glsl';
if (cancelled) return; const fragPath = fragmentShaderPath || '/shaders/distortion.frag.glsl';
scene.setShaderMaterial(vertex, fragment);
setIsReady(true);
};
if (!vertexShaderPath && !fragmentShaderPath) { console.log('[ImageDistortion] 셰이더 로드 시도:', { vertPath, fragPath });
// 번들된 셰이더 - 네트워크 왕복 없이 즉시 준비된다
apply(bundledVertexShader, bundledFragmentShader); shaderManagerRef.current
} else { .loadShaders(vertPath, fragPath)
// 커스텀 경로가 주어진 것만 fetch, 나머지는 번들 셰이더 .then(({ vertex, fragment }) => {
Promise.all([ console.log('[ImageDistortion] 셰이더 로드 성공');
vertexShaderPath ? fetchShaderSource(vertexShaderPath) : Promise.resolve(bundledVertexShader), scene.setShaderMaterial(vertex, fragment);
fragmentShaderPath ? fetchShaderSource(fragmentShaderPath) : Promise.resolve(bundledFragmentShader), setIsReady(true);
]) })
.then(([vertex, fragment]) => apply(vertex, fragment)) .catch((error) => {
.catch((error) => { console.error('[ImageDistortion] 셰이더 로드 실패:', error);
console.error('[ImageDistortion] 셰이더 로드 실패:', error); });
});
}
return () => { return () => {
cancelled = true;
scene.dispose(); scene.dispose();
if (textureRef.current) { if (textureRef.current) {
textureRef.current.dispose(); textureRef.current.dispose();
@ -162,14 +147,27 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
// 이미지 텍스처 로드 // 이미지 텍스처 로드
useEffect(() => { useEffect(() => {
if (!imageSrc || !isReady) return; if (!imageSrc || !isReady) {
console.log('[ImageDistortion] 이미지 로드 스킵:', {
imageSrc: describeSrc(imageSrc),
isReady,
});
return;
}
// describeSrc: data: URL을 그대로 찍으면 DevTools가 그 한 줄을 렌더하느라
// 메인 스레드가 수 초간 멈춘다. 너무 길면 길이만 남기고 본문은 버린다.
console.log('[ImageDistortion] 이미지 로드 시작:', describeSrc(imageSrc));
setImageLoaded(false); setImageLoaded(false);
const loader = new THREE.TextureLoader(); const loader = new THREE.TextureLoader();
loader.load( loader.load(
imageSrc, imageSrc,
(texture) => { (texture) => {
console.log('[ImageDistortion] 이미지 로드 성공!', {
width: texture.image.width,
height: texture.image.height
});
textureRef.current = texture; textureRef.current = texture;
setImageLoaded(true); setImageLoaded(true);
if (sceneRef.current) { if (sceneRef.current) {
@ -177,13 +175,14 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
u_texture: { value: texture }, u_texture: { value: texture },
}); });
sceneRef.current.render(); sceneRef.current.render();
if (!firstRenderNotifiedRef.current) { console.log('[ImageDistortion] 텍스처 업데이트 및 렌더링 완료');
firstRenderNotifiedRef.current = true;
onFirstRenderRef.current?.();
}
} }
}, },
undefined, (progress) => {
console.log('[ImageDistortion] 이미지 로딩 중...',
Math.round((progress.loaded / progress.total) * 100) + '%'
);
},
(error) => { (error) => {
console.error('[ImageDistortion] 이미지 로드 실패:', error); console.error('[ImageDistortion] 이미지 로드 실패:', error);
setImageLoaded(false); setImageLoaded(false);

View File

@ -199,10 +199,7 @@ export class SpriteEffectInstance {
// 초기 속성 // 초기 속성
particle.initialScale = randomRange(config.initialScale[0], config.initialScale[1]); particle.initialScale = randomRange(config.initialScale[0], config.initialScale[1]);
particle.scale = particle.initialScale; particle.scale = particle.initialScale;
const rotationRange = config.initialRotation; particle.rotation = 0;
particle.rotation = rotationRange
? (randomRange(rotationRange[0], rotationRange[1]) * Math.PI) / 180
: 0;
particle.opacity = 1; particle.opacity = 1;
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]); particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
particle.age = 0; particle.age = 0;
@ -240,17 +237,14 @@ export class SpriteEffectInstance {
* *
* @param deltaTime * @param deltaTime
* @param emitCenter ( 0-1) * @param emitCenter ( 0-1)
* @param holdActive hold -
*/ */
private _logCounter = 0; private _logCounter = 0;
update(deltaTime: number, emitCenter: Point, resolution?: { x: number; y: number }, holdActive = false): void { update(deltaTime: number, emitCenter: Point, resolution?: { x: number; y: number }): void {
if (!this.ready) return; if (!this.ready) return;
// 지속 방출 - ambient는 항상, hold는 포인터가 머무는 동안만 // ambient 방출
const emitting = this.config.trigger === 'ambient' if (this.config.trigger === 'ambient') {
|| (this.config.trigger === 'hold' && holdActive);
if (emitting) {
this.updateAmbientEmit(deltaTime, emitCenter); this.updateAmbientEmit(deltaTime, emitCenter);
} }

View File

@ -94,15 +94,13 @@ export class SpriteEffectManager {
update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState, resolution?: { x: number; y: number }): void { update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState, resolution?: { x: number; y: number }): void {
// 현재 터치 중인 영역 감지 // 현재 터치 중인 영역 감지
const currentTouchingAreas = new Set<string>(); const currentTouchingAreas = new Set<string>();
// 포인터가 머물고 있는 영역 - 마우스는 올려두기만 해도, 터치는 누르고 있는 동안 들어온다
const currentHoveringAreas = new Set<string>();
if (touchState.position) { if (touchState.isDragging && touchState.position) {
for (const area of effectAreas) { for (const area of effectAreas) {
const radius = area.radius ?? 0.1; const radius = area.radius ?? 0.1;
if (!isPointInCircle(touchState.position, area.position, radius, resolution)) continue; if (isPointInCircle(touchState.position, area.position, radius, resolution)) {
currentHoveringAreas.add(area.id); currentTouchingAreas.add(area.id);
if (touchState.isDragging) currentTouchingAreas.add(area.id); }
} }
} }
@ -122,15 +120,8 @@ export class SpriteEffectManager {
} }
} }
// hold 이펙트: 포인터가 머무는 동안 그 자리에서 계속 방출 - 트레일처럼 따라온다 // 매 프레임 업데이트 (ambient 방출 + 파티클 물리)
const isHolding = effectConfig.trigger === 'hold' instance.update(deltaTime, area.position, resolution);
&& 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
View File

@ -1,5 +0,0 @@
// .glsl 임포트를 문자열로 취급 (tsup --loader .glsl=text와 한 벌)
declare module '*.glsl' {
const source: string;
export default source;
}

View File

@ -1,12 +1,7 @@
import type { Point } from './area'; import type { Point } from './area';
/** /** 이펙트 트리거 타입 */
* export type SpriteEffectTrigger = 'ambient' | 'touch';
* - ambient: 영역
* - touch: 영역에
* - hold: 포인터가 ()
*/
export type SpriteEffectTrigger = 'ambient' | 'touch' | 'hold';
/** 블렌드 모드 */ /** 블렌드 모드 */
export type SpriteBlendMode = 'normal' | 'additive'; export type SpriteBlendMode = 'normal' | 'additive';
@ -79,8 +74,6 @@ export interface SpriteEffectAreaData {
initialScale: [number, number]; initialScale: [number, number];
initialSpeed: [number, number]; initialSpeed: [number, number];
emitAngle?: [number, number]; emitAngle?: [number, number];
/** [최소, 최대] spawn 시 회전 각도 (도) */
initialRotation?: [number, number];
emitOffset?: { x: number; y: number }; emitOffset?: { x: number; y: number };
emitRadius?: number; emitRadius?: number;
overLifetime?: SpriteParticleOverLifetime; overLifetime?: SpriteParticleOverLifetime;
@ -104,7 +97,7 @@ export interface SpriteEffectConfig {
blendMode?: SpriteBlendMode; blendMode?: SpriteBlendMode;
/** 최대 파티클 수 */ /** 최대 파티클 수 */
maxParticles: number; maxParticles: number;
/** ambient·hold: 초당 방출 수 */ /** ambient: 초당 방출 수 */
emitRate?: number; emitRate?: number;
/** touch: 터치 시 방출 수 */ /** touch: 터치 시 방출 수 */
burstCount?: number; burstCount?: number;
@ -116,8 +109,6 @@ export interface SpriteEffectConfig {
initialSpeed: [number, number]; initialSpeed: [number, number];
/** 방출 각도 범위 (도) */ /** 방출 각도 범위 (도) */
emitAngle?: [number, number]; emitAngle?: [number, number];
/** [최소, 최대] spawn 시 회전 각도 (도) - 파티클마다 이 범위에서 뽑아 그만큼 돌린 채로 나온다 */
initialRotation?: [number, number];
/** 영역 중심 대비 방출 오프셋 */ /** 영역 중심 대비 방출 오프셋 */
emitOffset?: Point; emitOffset?: Point;
/** 방출 범위 반경 */ /** 방출 범위 반경 */