import * as THREE from 'three'; import type { Point } from '@/types'; import type { SpriteEffectConfig, SpriteSheetConfig } 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; /** * 키프레임 보간 (2개: 선형, 3개: 전반/후반 분할) */ const lerpKeyframes = (values: number[], t: number): number => { if (values.length === 2) { return lerp(values[0], values[1], t); } // 3개 이상: 균등 구간 분할 const segments = values.length - 1; const segT = t * segments; const idx = Math.min(Math.floor(segT), segments - 1); const localT = segT - idx; return lerp(values[idx], values[idx + 1], localT); }; /** * 단일 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; }); // 이모지 또는 URL 텍스처 로드 if (config.emoji) { this.createEmojiTexture(config.emoji); } else if (config.spriteUrl) { this.loadTexture(config.spriteUrl); } else { console.error('[SpriteEffectInstance] spriteUrl 또는 emoji 중 하나는 필수입니다.'); } } /** 런타임 설정 업데이트 (maxParticles 제외) */ updateConfig(config: SpriteEffectConfig): void { this.config = config; } /** 텍스처 로드 */ private loadTexture(url: string): void { const loader = new THREE.TextureLoader(); loader.load( url, (texture) => { this.texture = texture; const sheet = this.config.spriteSheet; if (sheet) { // 스프라이트 시트: 첫 프레임만 표시하도록 UV 설정 texture.repeat.set(1 / sheet.columns, 1 / sheet.rows); texture.offset.set(0, 1 - 1 / sheet.rows); } // 각 메쉬에 독립적인 텍스처 클론 적용 (프레임별 UV 독립 제어) for (const mesh of this.meshes) { const mat = mesh.material as THREE.MeshBasicMaterial; if (sheet) { const cloned = texture.clone(); cloned.repeat.copy(texture.repeat); cloned.offset.copy(texture.offset); mat.map = cloned; } else { mat.map = texture; } mat.needsUpdate = true; } this.ready = true; console.log(`[SpriteEffectInstance] 텍스처 로드 성공: ${url}`, texture.image.width, 'x', texture.image.height); }, undefined, (error) => { console.error(`[SpriteEffectInstance] 텍스처 로드 실패: ${url}`, error); } ); } /** 이모지를 Canvas에 렌더링하여 텍스처 생성 */ private createEmojiTexture(emoji: string): void { const size = 128; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); if (!ctx) { console.error('[SpriteEffectInstance] Canvas 2D 컨텍스트 생성 실패'); return; } ctx.font = '100px serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(emoji, size / 2, size / 2); const texture = new THREE.CanvasTexture(canvas); this.texture = texture; // 각 메쉬에 텍스처 적용 (이모지는 스프라이트 시트 불필요) for (const mesh of this.meshes) { const mat = mesh.material as THREE.MeshBasicMaterial; mat.map = texture; mat.needsUpdate = true; } this.ready = true; console.log(`[SpriteEffectInstance] 이모지 텍스처 생성 완료: ${emoji}`); } /** * 파티클 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.initialScale = randomRange(config.initialScale[0], config.initialScale[1]); particle.scale = particle.initialScale; 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; particle.frameTime = 0; particle.frameIndex = 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) * @param holdActive hold 이펙트에서 포인터가 영역 안에 있는지 - 있는 동안만 방출한다 */ private _logCounter = 0; update(deltaTime: number, emitCenter: Point, resolution?: { x: number; y: number }, holdActive = false): void { if (!this.ready) return; // 지속 방출 - ambient는 항상, hold는 포인터가 머무는 동안만 const emitting = this.config.trigger === 'ambient' || (this.config.trigger === 'hold' && holdActive); if (emitting) { this.updateAmbientEmit(deltaTime, emitCenter); } const overLifetime = this.config.overLifetime; // 활성 파티클 업데이트 const activeParticles = this.pool.getActiveParticles(); if (this._logCounter++ % 60 === 0 && activeParticles.length > 0) { const p = activeParticles[0]; console.log(`[SpriteEffectInstance] 활성 파티클: ${activeParticles.length}개, 첫 파티클 pos=(${p.position.x.toFixed(3)}, ${p.position.y.toFixed(3)}), scale=${p.scale.toFixed(3)}, opacity=${p.opacity.toFixed(3)}`); } 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) { // overLifetime.scale은 initialScale에 대한 배율로 적용 particle.scale = particle.initialScale * lerpKeyframes(overLifetime.scale, lifeRatio); } if (overLifetime.opacity) { particle.opacity = lerpKeyframes(overLifetime.opacity, 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; } } // 스프라이트 시트 프레임 진행 if (this.config.spriteSheet) { this.updateSpriteFrame(particle, deltaTime, this.config.spriteSheet); } // 위치 업데이트 particle.position.x += particle.velocity.x * deltaTime; particle.position.y += particle.velocity.y * deltaTime; // Three.js 메쉬 동기화 this.syncMesh(particle, resolution); } } /** * 스프라이트 시트 프레임 진행 */ private updateSpriteFrame(particle: SpriteParticle, deltaTime: number, sheet: SpriteSheetConfig): void { particle.frameTime += deltaTime; const frameDuration = 1 / sheet.fps; if (particle.frameTime >= frameDuration) { particle.frameTime -= frameDuration; const nextFrame = particle.frameIndex + 1; if (nextFrame >= sheet.totalFrames) { // 루프 여부 확인 (기본: true) if (sheet.loop !== false) { particle.frameIndex = 0; } // loop=false면 마지막 프레임 유지 } else { particle.frameIndex = nextFrame; } } } /** * 파티클 상태를 Three.js 메쉬에 동기화 * 정규화 좌표(0-1) → NDC(-1~1) 변환, y축 반전 */ private syncMesh(particle: SpriteParticle, resolution?: { x: number; y: number }): 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.01; // 카메라가 -z를 바라보므로 음수가 앞쪽 // 종횡비 보정: OrthographicCamera(-1,1,1,-1) 기준 정사각형 NDC에서 직사각형 뷰포트 왜곡 방지 const aspect = resolution ? resolution.x / resolution.y : 1; mesh.scale.set(particle.scale / aspect, particle.scale, 1); mesh.rotation.z = particle.rotation; const mat = mesh.material as THREE.MeshBasicMaterial; mat.opacity = particle.opacity; // 스프라이트 시트 UV 오프셋 업데이트 const sheet = this.config.spriteSheet; if (sheet && mat.map) { const col = particle.frameIndex % sheet.columns; const row = Math.floor(particle.frameIndex / sheet.columns); mat.map.offset.set( col / sheet.columns, 1 - (row + 1) / sheet.rows, ); } } /** * 텍스처 로딩 완료 여부 */ 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]); } } }