一体化と分離
一体化と分離の作り方
リング状に並んだ 8 個の円がまわりながら順番に中心へ潜り、ひとつの大きな円に呑み込まれて、また離れて輪にもどる。合体も分離も、正体は「集まる・待つ・もどる」のクリップ 1 本を円ごとに遅らせて再生したものだ。これに一定の速さで回りつづける輪が重なる。溶けて見えるのは同じ色の円が重なるからで、まん中の円は面積の帳尻が合うように平方根でふくらむ。コードつきで、組み立ての考え方から順に見ていく。
- Published
- 2026年6月12日
- Topics
- Stagger · Overshoot · Easing · SVG
クリップ 1 本と円の複製 8 個を用意する
- まわる輪 — 円を
copyCount個、中心のまわりに等間隔で置き、輪ごと毎フレームturnPerFrameずつ回しつづける(最後まで止めない) - 進み具合 p — 0 が輪の上の定位置(席)、1 が中心にぴったり重なった状態で、「集まる → 待つ → もどる」のクリップ
progressAtがこの p を返す - ずらし再生 — i 番目の円は同じクリップを
staggerStep× i フレーム遅らせて読む(順番に潜る・順番にもどるは、これだけで出る) - まん中の円 — 各円の呑み込まれ具合
absorbAtの平均の平方根を、満杯半径coreFullRadiusに掛ける
合体も分離も振り付けていない — あるのは「集まる → 待つ → もどる」のクリップ 1 本だ。主役の関数は 3 つで、脇に緩急の小さなヘルパーが付く。クリップ本体の progressAt、呑み込まれ具合を出す absorbAt、フレーム番号から全部の円の置き場所を返す blobsAt だ。
// 芯は「集まる → 待つ → もどる」のクリップ 1 本 — 8 個の円は止まらない輪に乗って、同じクリップを遅らせ再生する。
// 小文字ではじまる裸の名前(gatherSpan や ringRadius)は、自分の画面に合わせて決める数 —
// この記事が持ち帰ってほしいのは値ではなく、この「形」だ。
const easeIn = (t) => t * t * t; // 集まりの緩急 — そっと離れて、中心へ加速着地
const easeOutBack = (t) => {
// もどりの緩急 — 定位置を少し行き過ぎてから落ち着く。行き過ぎ量は backOvershoot
const u = t - 1;
return 1 + (backOvershoot + 1) * u * u * u + backOvershoot * u * u;
};
// クリップ本体: 進み具合を返す。0 が輪の上の定位置、1 が中心。loopFrames ごとに繰り返す
function progressAt(frame) {
const t = ((frame % loopFrames) + loopFrames) % loopFrames; // マイナスのフレームでも 0〜loopFrames に巻き戻す(遅らせ再生は頭より前を読むことがある)
if (t < gatherSpan) return easeIn(t / gatherSpan); // 集まる
if (t < gatherSpan + holdSpan) return 1; // 中心で待つ
if (t < gatherSpan + holdSpan + backSpan)
return 1 - easeOutBack((t - gatherSpan - holdSpan) / backSpan); // もどる
return 0; // 輪の上で待機
}
// なだらかな S 字で 0→1 を渡る(いわゆる smoothstep)
const smooth = (s) => {
const u = Math.min(1, Math.max(0, s));
return u * u * (1 + 2 - 2 * u);
};
// 呑み込まれ具合: 中心に着いてから absorbWindow のあいだで 1 へ、離れはじめたら同じ長さで 0 へ
function absorbAt(frame) {
const t = ((frame % loopFrames) + loopFrames) % loopFrames;
return smooth((t - gatherSpan) / absorbWindow) * (1 - smooth((t - gatherSpan - holdSpan) / absorbWindow));
}
// フレーム番号を渡すと、まん中の円と 8 個の円の置き場所を返す関数
function blobsAt(frame) {
// まわる輪: 等間隔に置き、毎フレーム turnPerFrame ぶんの回転を足しつづける
const ring = Array.from({ length: copyCount }, (_, i) => {
const a = ((ringStart + turnPerFrame * frame + (360 / copyCount) * i) * Math.PI) / 180;
return { x: centerX + ringRadius * Math.cos(a), y: centerY + ringRadius * Math.sin(a) };
});
// ずらし再生: i 番目はおなじクリップを staggerStep × i フレーム遅れで読む
const localFrames = ring.map((_, i) => frame + clipShift - i * staggerStep);
// 各円: 輪の上の定位置から中心へ、進み具合のぶんだけ寄せる
const dots = ring.map((pt, i) => {
const p = progressAt(localFrames[i]);
return { x: pt.x + (centerX - pt.x) * p, y: pt.y + (centerY - pt.y) * p, r: dotRadius };
});
// まん中の円: 呑み込まれ具合の平均の平方根を満杯半径に掛ける(面積は呑んだ個数に比例)
const share = localFrames.map((f) => absorbAt(f)).reduce((sum, a) => sum + a, 0) / copyCount;
const coreR = coreFullRadius * Math.sqrt(share);
const core = coreR > minVisibleRadius ? { x: centerX, y: centerY, r: coreR } : null;
return { core, dots };
}緩急は 2 つだけ。集まり側の easeIn は t * t * t の 1 行で、そっと離れて中心へ加速着地する顔になる。もどり側の easeOutBack は定位置を少し行き過ぎてから落ち着く定番のカーブで、行き過ぎの量は backOvershoot で決める。CSS の cubic-bezier() でも縦の数を 1 より大きく振れば同じ表情が作れるから、手元のスタックに合わせて選べばいい。
progressAt はキーフレームを 1 個も持たない。いまの時刻がどの区間にいるかを gatherSpan・holdSpan・backSpan の長さで順に調べ、区間ごとの式で進み具合を返すだけ — 最後まで来たら 0 を返し、ループ loopFrames の残り時間は輪の上で休む。clipShift は再生開始位置の調整で、ループの頭を「全員が中心に集まった状態」に合わせている。
まん中の円は absorbAt がふくらませる。円が中心に着くと absorbWindow のあいだになだらかに 1 へ、離れはじめると同じ長さで 0 へもどる「呑み込まれ具合」で、8 個ぶんの平均の平方根を満杯半径 coreFullRadius に掛ける。平方根なのは面積の勘定だ。円の面積は半径の 2 乗に比例するから、こうすると面積が呑んだ個数にまっすぐ比例して、1 個呑むごとに満杯の 8 分の 1 ずつ増える。
描くほうは円だけだ。blobsAt が返す置き場所どおりに circle を置けばよく、SVG でも canvas でもいい。効かせどころは、全部を 1 色で塗ること。溶け合いは塗りが勝手にやってくれるから、どの円がくっついたかを管理するコードは要らない。届かないのはそこだけ — 本物は合体の瞬間、くびれが水あめのように内側からなめらかに埋まるが、素の重なりではそこまで出ない。minVisibleRadius は、まん中の円が小さすぎるフレームで描画を省く足切りだ。
もどりの行き過ぎが、この動きの表情を決めている。吐き出された円は輪の定位置を通り越して外へ少しはみ出し、それから席に落ち着く。easeOutBack の仕事はそこだけだが、ただの巻き戻しに見えない理由のほとんどはこの一瞬にある。
動かすときは requestAnimationFrame で経過秒に毎秒のコマ数を掛けて frame を作ればいい。このデモのループの継ぎ目は 2 つの仕掛けで隠れている。輪は 1 ループでちょうど半回転して、8 個の等間隔の輪は元の自分にぴったり重なる。しかも継ぎ目の瞬間は全員が中心に潜っている時間で、入れ替わりはまん中の円の下に隠れる — 円たちは毎周、向かいの席に座り直しているのに、誰にも見えない。
自分の数値で組むときの約束は 2 つ。turnPerFrame × loopFrames は席の間隔(360 を copyCount で割った角度)の整数倍に — このデモでは半回転。clipShift は「全員が潜り終えて、まだ誰も出ていない」時間へループの頭を置く — gatherSpan + staggerStep × (円の個数 − 1) から gatherSpan + holdSpan までだ。
progressAt が返すのは 0〜1 の数値で、位置のほかに何へ掛けてもいい。研究リポの汎用デモでは正方形 5 個・別のずらし幅・別の区間長で同じクリップを再生し、進み具合を位置・大きさ・回転に同時に掛けた — それでも「集まって、待って、もどる」の呼吸は残った。holdSpan を削れば慌ただしく、backSpan を伸ばせば優雅になる。ただし全員呑み込みの絵を残すには holdSpan をずらしの合計より長く。