Vanilla Disintegratev1.6.1
KO
이 페이지에서

사용자 효과는 애니메이션 두 개입니다. 하나는 요소를 지울 때, 하나는 되돌릴 때 씁니다. 애니메이션과 사운드는 직접 작성합니다. 라이브러리는 요소를 캡처하고 위치를 재며, 취소와 정리를 맡습니다.

플레이그라운드에서 파티클 설정하기

홈 페이지의 플레이그라운드는 삭제와 복원에 각각 독립적인 파티클 및 사운드 설정을 가진 완성된 사용자 효과를 생성합니다. 예를 들어 다음 코드는 scatter 삭제와 vapor 복원을 결합합니다.

import Disintegrator, { createParticleEffect, particlePresets } from 'vanilla-disintegrate/snapdom';

const sharedSoundOptions = {
  volume: 0.32,
  playbackRate: 1,
  delay: 0,
  fadeDuration: 0.18,
};

export const disintegrator = new Disintegrator({
  effect: createParticleEffect({
    remove: particlePresets.scatter,
    restore: particlePresets.vapor,
  }),
  sound: {
    remove: {
      ...sharedSoundOptions,
      src: 'scatter',
      reverse: false,
    },
    restore: {
      ...sharedSoundOptions,
      src: 'vapor',
      reverse: true,
    },
  },
});

createParticleEffect()는 내장 파티클 렌더러를 사용합니다. 플레이그라운드에서 두 단계를 설정하고 생성된 코드를 복사하면 Canvas 또는 WebGL 렌더러를 직접 작성하지 않아도 됩니다. 렌더링 자체를 구현하려는 경우에만 다음 절이 필요합니다.

모든 움직임 옵션의 기본값과 허용 범위, 저수준 단계 팩토리는 파티클 API를 참고하세요.

파티클 렌더링 품질 제어

renderQuality는 캡처 어댑터와 별개로 내장 WebGL 렌더러의 해상도를 제어합니다. 기본 auto 모드는 메모리와 GPU 사용량을 제한합니다. 렌더러의 소스 텍스처는 최대 2,000,000픽셀, 한 변은 최대 2,048픽셀이며 확장된 애니메이션 표면은 최대 4,000,000픽셀입니다.

캡처의 전체 해상도를 유지하는 것이 이러한 소프트웨어 제한보다 중요하면 exact를 사용하세요.

const exactEffect = createParticleEffect({
  remove: { ...particlePresets.vapor, renderQuality: 'exact' },
  restore: { ...particlePresets.scatter, renderQuality: 'exact' },
});

직접 예산을 지정할 수도 있습니다.

const boundedEffect = createParticleEffect({
  remove: {
    ...particlePresets.vapor,
    renderQuality: {
      maxSourcePixels: 4_000_000,
      maxSourceDimension: 4096,
      maxRenderPixels: 8_000_000,
    },
  },
});

exact는 소프트웨어 다운스케일링을 끄지만 장치의 WebGL 텍스처 및 뷰포트 한계를 우회할 수는 없습니다. 필요한 정확한 표면을 장치가 지원하지 않으면 품질을 조용히 낮추는 대신 onError로 오류를 보고합니다. 크기를 알 수 없거나 큰 요소에는 auto를 권장합니다. 전체 해상도의 비용은 device pixel ratio의 제곱에 비례해 증가합니다.

WebGL 컨텍스트 상한 조정

import { configureParticleContexts } from 'vanilla-disintegrate/snapdom';

configureParticleContexts({ maxContexts: 2, maxIdleContexts: 1 });

파티클 렌더러는 페이지 전체에서 한 번에 최대 4개의 WebGL2 컨텍스트를 유지하며, 그중 최대 2개는 효과가 끝난 뒤 재사용을 위해 예열된 채로 남습니다. 이 상한은 효과별이 아니라 페이지 전체 단위입니다. 브라우저 자체가 이미 페이지가 유지할 수 있는 활성 컨텍스트 수를 제한하고, 두 효과가 서로 다른 값을 요청한다면 어느 쪽도 유일하게 옳은 답이 될 수 없기 때문입니다.

페이지가 상한에 도달해 컨텍스트를 얻지 못한 효과는 WebGL2가 전혀 없는 브라우저와 같은 경로를 따릅니다. remove()는 요소를 그대로 분리하고 restore()는 그대로 드러냅니다. fallback이 설정되어 있으면 그것이 대신 실행되고, 없으면 작업은 skipped 상태로 끝나며 onError는 경합이 원인이라고 짚어주는 대신 “애니메이션을 만들지 못했다”는 일반적인 오류를 전달합니다. 어떤 효과든 실행되기 전에 configureParticleContexts()를 한 번 호출하면 페이지가 살아있는 동안 이 상한이 바뀝니다. 메모리가 제한된 장치에서는 낮추고, 높일 때는 브라우저 자체가 허용하는 한도까지만 올리세요. 그 이상은 컨텍스트가 버려지는 지점을 옮길 뿐입니다.

스냅샷으로 파티클 효과 만들기

아래 예제는 snapshot에서 불투명 픽셀을 읽어 Canvas 파티클로 바꾸고 소용돌이를 따라 움직입니다. 이 효과는 스냅샷을 사용하며 needsSnapshot: true가 이미 기본값이므로 해당 옵션을 생략할 수 있습니다.

import { defineEffect, type AnimationFactory, type SoundFactory } from 'vanilla-disintegrate';

type VortexPhase = 'collapse' | 'expand';

interface VortexParticle {
  readonly angle: number;
  readonly color: string;
  readonly delay: number;
  readonly radius: number;
  readonly size: number;
  readonly twist: number;
}

const PARTICLE_BUDGET = 900;
const TAU = Math.PI * 2;

function clamp(value: number) {
  return Math.min(1, Math.max(0, value));
}

function smoothstep(value: number) {
  const progress = clamp(value);
  return progress * progress * (3 - 2 * progress);
}

function sampleParticles(
  snapshot: HTMLCanvasElement,
  sourceX: number,
  sourceY: number,
  centerX: number,
  centerY: number,
  random: () => number,
) {
  const context = snapshot.getContext('2d', { willReadFrequently: true });
  if (context === null) return [];
  const pixels = context.getImageData(0, 0, snapshot.width, snapshot.height).data;
  const step = Math.max(2, Math.ceil(Math.sqrt((snapshot.width * snapshot.height) / PARTICLE_BUDGET)));
  const particles: VortexParticle[] = [];

  for (let y = Math.floor(step / 2); y < snapshot.height; y += step) {
    for (let x = Math.floor(step / 2); x < snapshot.width; x += step) {
      const offset = (y * snapshot.width + x) * 4;
      const alpha = pixels[offset + 3] ?? 0;
      if (alpha < 20) continue;
      const targetX = sourceX + x;
      const targetY = sourceY + y;
      const deltaX = targetX - centerX;
      const deltaY = targetY - centerY;
      const direction = random() < 0.5 ? -1 : 1;
      particles.push({
        angle: Math.atan2(deltaY, deltaX),
        color: `rgb(${String(pixels[offset] ?? 0)} ${String(pixels[offset + 1] ?? 0)} ${String(
          pixels[offset + 2] ?? 0,
        )} / ${String(alpha / 255)})`,
        delay: random() * 0.16,
        radius: Math.hypot(deltaX, deltaY),
        size: step * (0.62 + random() * 0.5),
        twist: direction * TAU * (1.35 + random() * 1.65),
      });
    }
  }

  return particles;
}

function createVortexParticles(phase: VortexPhase): AnimationFactory {
  const duration = phase === 'collapse' ? 720 : 860;
  return ({ bounds, random, signal, snapshot }) => {
    if (snapshot === null) return null;
    const scaleX = snapshot.width / bounds.width;
    const scaleY = snapshot.height / bounds.height;
    const padding = Math.min(150, Math.max(54, Math.max(bounds.width, bounds.height) * 0.3));
    const sourceX = padding * scaleX;
    const sourceY = padding * scaleY;
    const canvas = document.createElement('canvas');
    canvas.width = Math.ceil(snapshot.width + sourceX * 2);
    canvas.height = Math.ceil(snapshot.height + sourceY * 2);
    Object.assign(canvas.style, {
      height: `${bounds.height + padding * 2}px`,
      left: `${-padding}px`,
      pointerEvents: 'none',
      position: 'absolute',
      top: `${-padding}px`,
      width: `${bounds.width + padding * 2}px`,
    });
    const context = canvas.getContext('2d');
    if (context === null) return null;
    const centerX = sourceX + snapshot.width / 2;
    const centerY = sourceY + snapshot.height / 2;
    const particles = sampleParticles(snapshot, sourceX, sourceY, centerX, centerY, random);
    if (particles.length === 0) return null;

    const render = (progress: number) => {
      context.clearRect(0, 0, canvas.width, canvas.height);
      const snapshotAlpha =
        phase === 'collapse' ? 1 - smoothstep(progress / 0.28) : smoothstep((progress - 0.68) / 0.32);
      const particleBlend =
        phase === 'collapse' ? smoothstep(progress / 0.24) : 1 - smoothstep((progress - 0.74) / 0.26);
      if (snapshotAlpha > 0) {
        context.globalAlpha = snapshotAlpha;
        context.drawImage(snapshot, sourceX, sourceY);
        context.globalAlpha = 1;
      }
      const portalStrength = Math.sin(progress * Math.PI);
      context.save();
      context.globalCompositeOperation = 'lighter';
      context.strokeStyle = `rgb(255 94 190 / ${String(portalStrength * 0.72)})`;
      context.lineWidth = Math.max(2, 5 * scaleX * portalStrength);
      context.beginPath();
      context.arc(centerX, centerY, (12 + portalStrength * 38) * scaleX, 0, TAU);
      context.stroke();
      context.restore();

      for (const particle of particles) {
        const local = clamp((progress - particle.delay) / (1 - particle.delay));
        let radiusFactor: number;
        let angle: number;
        let alpha: number;
        let sizeFactor: number;
        if (phase === 'collapse') {
          const motion = Math.pow(local, 2.35);
          radiusFactor = 1 - motion;
          angle = particle.angle + particle.twist * motion;
          alpha = 1 - smoothstep((local - 0.78) / 0.22);
          sizeFactor = 1 - motion * 0.76;
        } else {
          const motion = 1 - Math.pow(1 - local, 2.8);
          radiusFactor = motion;
          angle = particle.angle - particle.twist * (1 - motion);
          alpha = smoothstep(local / 0.24);
          sizeFactor = 0.24 + motion * 0.76;
        }
        const x = centerX + Math.cos(angle) * particle.radius * radiusFactor;
        const y = centerY + Math.sin(angle) * particle.radius * radiusFactor;
        context.globalAlpha = alpha * particleBlend;
        context.fillStyle = particle.color;
        context.beginPath();
        context.arc(x, y, Math.max(0.7, particle.size * sizeFactor * 0.5), 0, TAU);
        context.fill();
      }
      context.globalAlpha = 1;
    };

    let frame = 0;
    let startedAt: number | null = null;
    let settled = false;
    let disposed = false;
    let resolveFinished: () => void = () => undefined;
    const finished = new Promise<void>((resolve) => {
      resolveFinished = resolve;
    });
    const finish = () => {
      if (settled) return;
      settled = true;
      cancelAnimationFrame(frame);
      resolveFinished();
    };
    const tick = (time: number) => {
      if (settled) return;
      startedAt ??= time;
      const progress = clamp((time - startedAt) / duration);
      render(progress);
      if (progress >= 1) {
        finish();
      } else {
        frame = requestAnimationFrame(tick);
      }
    };
    const cancel = () => finish();
    const dispose = () => {
      if (disposed) return;
      disposed = true;
      finish();
      signal.removeEventListener('abort', cancel);
      canvas.width = 0;
      canvas.height = 0;
    };

    render(0);
    signal.addEventListener('abort', cancel, { once: true });
    frame = requestAnimationFrame(tick);
    return { element: canvas, duration, finished, cancel, dispose };
  };
}

const createVortexTone: SoundFactory = ({ operation, signal }) => {
  const context = new AudioContext();
  const oscillator = context.createOscillator();
  const gain = context.createGain();
  const remove = operation === 'remove';
  oscillator.type = 'sine';
  oscillator.frequency.setValueAtTime(remove ? 560 : 150, context.currentTime);
  oscillator.frequency.exponentialRampToValueAtTime(remove ? 110 : 680, context.currentTime + 0.48);
  gain.gain.setValueAtTime(0.0001, context.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.075, context.currentTime + 0.035);
  gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.52);
  oscillator.connect(gain).connect(context.destination);
  oscillator.start();
  oscillator.stop(context.currentTime + 0.54);
  let disposed = false;
  const dispose = () => {
    if (disposed) return;
    disposed = true;
    signal.removeEventListener('abort', dispose);
    try {
      oscillator.stop();
    } catch {
      // The oscillator may already have reached its scheduled stop.
    }
    void context.close();
  };
  signal.addEventListener('abort', dispose, { once: true });
  oscillator.addEventListener('ended', dispose, { once: true });
  return { stop: dispose, dispose };
};

export const particleVortex = defineEffect({
  remove: {
    animate: createVortexParticles('collapse'),
  },
  restore: {
    animate: createVortexParticles('expand'),
  },
});

export const particleVortexSounds = Object.freeze({
  remove: createVortexTone,
  restore: createVortexTone,
});

위 코드를 particle-vortex.ts 파일로 저장한 다음 particleVortex를 가져와 생성자에 전달하세요.

import Disintegrator from 'vanilla-disintegrate/snapdom';

import { particleVortex } from './particle-vortex';

const effects = new Disintegrator({ effect: particleVortex });

effects.remove(element, { retain: true });
effects.restore(insertedElement);

위 데모와 코드 블록은 같은 파일에서 나옵니다. 돌아가는 그대로가 여러분이 가져가는 것입니다.

needsSnapshot: false는 DOM, SVG처럼 픽셀이 필요 없는 대상을 움직이는 효과에 맞습니다. 그 단계는 캡처를 아예 건너뜁니다.

WebGL2 없는 fallback

requires: 'webgl2' 단계에는 생성자의 fallback으로 동일한 remove/restore 쌍을 전달할 수 있습니다. CSS, Web Animations API, Canvas 2D, SVG 또는 자체 렌더러를 사용할 수 있지만 WebGL2는 사용할 수 없습니다. WebGL2를 사용할 수 없거나 내장 파티클 렌더러가 시작하지 못하면 실행되며, WebGL2를 사용할 수 없을 때는 라이브러리가 캡처 전에 fallback을 선택합니다. 내장 사운드 없이 실행됩니다. 전체 계약과 예제는 API을 참고하세요.

스냅샷 없는 코어 사용하기

두 단계 모두 needsSnapshot: false라면 /core를 가져오세요. 캡처 어댑터, 파티클 렌더러, 내장 오디오가 모듈 그래프에 들어오지 않습니다.

import Disintegrator, { defineEffect } from 'vanilla-disintegrate/core';

const fade = defineEffect({
  remove: {
    needsSnapshot: false,
    animate: ({ element, layer }) => {
      const visual = element.cloneNode(true) as HTMLElement;
      Object.assign(visual.style, {
        height: '100%',
        inset: '0',
        margin: '0',
        pointerEvents: 'none',
        position: 'absolute',
        width: '100%',
      });
      layer.append(visual);
      return visual.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 240 });
    },
  },
  restore: {
    needsSnapshot: false,
    animate: ({ element, layer }) => {
      const visual = element.cloneNode(true) as HTMLElement;
      Object.assign(visual.style, {
        height: '100%',
        inset: '0',
        margin: '0',
        pointerEvents: 'none',
        position: 'absolute',
        width: '100%',
      });
      layer.append(visual);
      return visual.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 240 });
    },
  },
});

const effects = new Disintegrator({ effect: fade });

애니메이션 컨텍스트

팩토리가 받는 값입니다.

  • operation — remove 또는 restore
  • element — 요소 자체
  • bounds — 화면상의 위치, DOMRectReadOnly
  • layer — 그 위치에 이미 놓인 빈 오버레이
  • snapshot — 캡처된 캔버스 또는 null
  • visual — 스냅샷을 그려 넣은 원본 크기 캔버스, 처음 쓸 때 만들어짐
  • signal — 작업이 취소되거나 인스턴스가 파괴되면 발동
  • random() — 이 작업용 난수, 시드가 있어 재현 가능
  • reducedMotion — 팩토리가 실행되는 동안 항상 false; 동작 축소 요청은 호출 전에 작업을 건너뜀
  • addCleanup(fn) — 여기 등록한 정리는 어떤 결과에도 실행됨

복원 시 bounds는 삭제 전 위치가 아닌 삽입 후 새 위치를 나타냅니다.

반환 값

애니메이션 팩토리는 다음 값을 반환할 수 있습니다.

type AnimationResult =
  | Animation
  | PromiseLike<unknown>
  | {
      element?: HTMLElement;
      finished: PromiseLike<unknown>;
      duration?: number;
      layoutDelay?: number;
      cancel?(): void;
      dispose?(): void;
    }
  | null;

Web Animation, Promise, Canvas나 WebGL 루프, 다른 엔진의 래퍼가 모두 이 계약에 맞습니다. cancel()은 재생을 멈추고 dispose()는 완료, 취소, 실패 여부와 관계없이 렌더러 자원을 해제합니다.

나만의 프리셋 만들기

사용자 effect는 기본적으로 무음입니다. 애니메이션과 오디오를 하나의 완성된 선택으로 함께 재사용하려면 완성 프리셋으로 묶으세요.

import { definePreset } from 'vanilla-disintegrate';

const vortex = definePreset({
  effect: particleVortex,
  sound: {
    remove: { src: 'vapor', volume: 0.35 },
    restore: { src: 'vapor', volume: 0.35, reverse: true },
  },
});

const effects = new Disintegrator({
  preset: 'vortex',
  presets: { vortex },
});

effects.remove(element);

내장 프리셋 이름은 루트 및 /snapdom 엔트리가 등록합니다. /core는 presets로 제공하거나 preset에 인라인으로 전달한 프리셋만 사용합니다.

사용자 사운드

사운드는 시각 단계와 분리하세요. 생성자는 독립된 remove와 restore 정의를 받으며 사용자 효과가 무음이어야 하면 sound를 생략합니다.

const effects = new Disintegrator({
  effect: particleVortex,
  sound: {
    remove: { src: '/sounds/remove.ogg', volume: 0.35 },
    restore: ({ signal }) => startMyAudioEngine(signal),
  },
});

src는 내장 이름, URL, 로컬 Blob/File, ArrayBuffer, typed-array view 또는 AudioBuffer도 받습니다. 오디오 오류는 onError로 전달되지만 애니메이션과 DOM 변경은 계속됩니다.

GitHub에서 이 페이지 편집