responsive-image-canvas/src/engine/SpriteEffectInstance.ts
윤정민 692f9b1260 Add a hold trigger that trails the pointer
- SpriteEffectTrigger에 hold 추가 - 포인터가 영역 안에 머무는 동안 그 자리에서 계속 방출
- 매니저가 hover 영역을 따로 잡아 방출 중심을 포인터 위치로 넘김 (마우스는 올려두기만 해도, 터치는 누르는 동안)
- 버전 1.7.0으로 올림
2026-08-18 13:51:56 +09:00

399 lines
12 KiB
TypeScript

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