時間遅延
時間遅延の作り方
5 つのカプセルが次々と跳ね、波のように見える。この波の正体は、1 本のジャンプと着地のプロファイルを、体ごとに時間をずらして再生したものだ。そのズレこそが「時間遅延」だ。コードつきで、組み立ての考え方から順に見ていく。
- Published
- 2026年6月14日
- Topics
- Stagger · Time Delay · Easing · SVG
1 本のプロファイルと、体ごとの時間ずらしを用意する
- 1 本のジャンプと着地のプロファイル — 休み → 上り → 頂点 → 下り、着地後はゆれておさまる
- プロファイルに乗る 2 つの派生 — 速いほど縦に伸びる体と、着地で飛ぶしずく
- 体ごとの時間ずらし — 同じプロファイルを
staggerFramesだけ遅らせて再生する(これが時間遅延)
const wrap = (value, span) => ((value % span) + span) % span;
// 着地の入り口速度 — 落ちカーブの出口の傾きから出す(あとのゆれの出だしになる)
const landVelocity = () => {
const [, , c2x, c2y] = fallBezier;
const slope = 1 - c2x > 0 ? (1 - c2y) / (1 - c2x) : 0;
return slope * (restCy - cyApex) / fallDur;
};
// 高さ cy(tau): 休み → 上り → 頂点 → 下り、着地後はゆれておさまる
const cyAt = (tau) => {
const amp = restCy - cyApex;
if (tau < riseDur) return restCy - amp * cubicBezier(...riseBezier)(tau / riseDur);
const landTau = riseDur + fallDur;
if (tau < landTau) return cyApex + amp * cubicBezier(...fallBezier)((tau - riseDur) / fallDur);
const t = tau - landTau;
const v = landVelocity();
return restCy + (v / settleOmega) * Math.exp(-settleLambda * t) * Math.sin(settleOmega * t);
};
// 速度 vy(tau): 同じカーブの傾き。伸び縮みはこれを読む
const vyAt = (tau) => {
const amp = restCy - cyApex;
if (tau < riseDur) return (-amp / riseDur) * cubicBezierSlope(...riseBezier)(tau / riseDur);
const landTau = riseDur + fallDur;
if (tau < landTau) return (amp / fallDur) * cubicBezierSlope(...fallBezier)((tau - riseDur) / fallDur);
const t = tau - landTau;
const v = landVelocity();
const e = Math.exp(-settleLambda * t);
return (v / settleOmega) * e * (settleOmega * Math.cos(settleOmega * t) - settleLambda * Math.sin(settleOmega * t));
};cyAt が高さのプロファイルだ。riseBezier で上り、頂点をはさんで fallBezier で下り、着地のキーを過ぎるとゆれておさまる正弦に切り替わる — その正弦の出だしの速さは、着地の入り口速度がそのまま決める(勝手なゆれは足さない)。riseBezier・fallBezier の中身は CSS の cubic-bezier() と同じ 4 つの数だ。速度 vyAt は同じカーブの傾き、つまり高さがどれだけ速く変わるかで、解析的に出しても、位置を差分しても得られる。
// しずく: 分離フレームを起点に、半径は等比でしぼみ、cy は行き先へ寄っていく
const satelliteAt = (sourceFrame, cx) => {
const half = periodFrames / 2;
const age = wrap(sourceFrame - separationFrame + half, periodFrames) - half;
if (age < -preSeparationFrames || age >= lifeFrames) return null;
return {
cx,
cy: cyAsymptote + (cyAnchor - cyAsymptote) * cyRatio ** age,
r: rAnchor * rDecay ** age,
};
};
function dotsAt(frame) { // フレーム番号 → 各カプセルの状態。同じ動きを時間ずらしで再生する
return Array.from({ length: dotCount }, (_, i) => {
const sourceFrame = wrap(frame - i * staggerFrames, periodFrames); // ← 時間遅延の正体
const tau = wrap(sourceFrame - tLift, periodFrames);
const scaleY = 1 + stretchK * Math.abs(vyAt(tau));
const cx = dotCx0 + i * dotSpacing;
return {
cx,
cy: cyAt(tau),
width: sizeBasePx,
height: sizeBasePx * scaleY,
satellite: satelliteAt(sourceFrame, cx),
};
});
}ここが時間遅延の正体だ。dotsAt は同じ 1 本のプロファイルを、体ごとに staggerFrames だけ巻き戻して読むだけ — 体ごとに別の動きを持たせるコードはどこにもない。次々と跳ねる波は、1 本の動きを少しずつ遅らせて並べた結果だ。縦の伸びは速さをそのまま読み(stretchK 倍)、しずくは着地のタイミングで satelliteAt がひとつ飛ばす。
画面に置くのはカプセルを 5 つ、返ってきた cx・cy・width・height の角丸長方形として描き、satellite があれば円を足すだけだ。動かすのは requestAnimationFrame で、経過時間にコマ数を掛けてフレーム番号を出し、periodFrames で wrap して dotsAt に渡せば、このデモと同じループになる。
遊ぶなら staggerFrames を書き換える。小さくすると波が詰まっていっせいに近づき、大きくすると一体ずつ遅れてカスケードが伸びる。tLift をずらせば跳ねる位置が動き、プロファイルそのものを別の上り下りに差し替えても、時間ずらしで再生する仕組みはそのまま使える。