Vanilla Disintegratev1.6.1
ZH
本页内容

自定义效果就是两段动画:一段用于删除元素,一段用于把它放回来。动画和声音由你来写。库负责捕获元素、测量位置,并处理取消和清理。

通过游乐场配置粒子

主页上的游乐场会生成完整的自定义效果,并为删除与恢复分别设置粒子和声音。例如,以下代码组合了 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 的备用效果

对于带有 requires: 'webgl2' 的阶段,可以在构造函数中传入包含同一对 remove/restore 的 fallback。它可使用 CSS、Web Animations API、Canvas 2D、SVG 或自定义渲染器,但不能使用 WebGL2。WebGL2 不可用或内置粒子渲染器无法启动时,它都会运行;WebGL2 不可用时,库会在捕获之前选择它。它不会播放内置声音。完整契约和示例请参阅 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 上编辑此页