自定义效果就是两段动画:一段用于删除元素,一段用于把它放回来。动画和声音由你来写。库负责捕获元素、测量位置,并处理取消和清理。
通过游乐场配置粒子
主页上的游乐场会生成完整的自定义效果,并为删除与恢复分别设置粒子和声音。例如,以下代码组合了 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或restoreelement— 元素本身bounds— 它在屏幕上的位置,DOMRectReadOnlylayer— 一个空的覆盖层,已经摆在这个位置上snapshot— 捕获到的画布,或nullvisual— 画好快照的全尺寸画布,首次访问时才创建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 变更会继续执行。