干渉
干渉の作り方
内側の点がリングを回り、そばを通るたびにリングの点がよける。まるで近づくものに反応しているように見えるが、リングの点の動きの正体は、回り続ける時計でくり返す同じ 1 本のパルスだ。よけて見えるのは、その上に重ねた距離の力による。コードつきで、組み立ての考え方から順に見ていく。
- Published
- 2026年6月14日
- Topics
- Interference · Proximity · Easing · SVG
回る点と、よけて見えるリングを組む
- 手で置くのは、内側を一定の速さで回る点ひとつ — これが通り過ぎる側
- リングの点は、1 本のパルスを整数クロックぶんずつ遅らせてくり返す(止まらない時計)
- その上に、通り過ぎる点との距離で決まる力を重ねる — 近いほど強く、離れると二乗で弱まる
const wrap = (frame, span) => ((frame % span) + span) % span;
// パルス本体: ピークから下り、出口の速さが種になって小さく揺れて収まる
const pulseExitSlope = () => {
const [, , x2, y2] = decayBez;
return ((1 - y2) / (1 - x2)) * ((0 - peak) / decayZeroTau);
};
const pulseAt = (tau) => {
const t = wrap(tau, periodFrames);
let base = 0;
if (t <= decayZeroTau) base = peak + (0 - peak) * cubicBezier(...decayBez)(t / decayZeroTau);
else if (t >= attackStartTau) base = peak * cubicBezier(...attackBez)((t - attackStartTau) / (periodFrames - attackStartTau));
const v = pulseExitSlope();
const settle = [0, 1].reduce((sum, m) => {
const d = wrap(t - decayZeroTau, periodFrames) + m * periodFrames;
return sum + (v * Math.exp(-settleLambda * d) * Math.sin(settleOmega * d)) / settleOmega;
}, 0);
return base + settle;
};pulseAt が 1 本のパルスだ。decayZeroTau まではピークから元の位置へ下り、attackStartTau から先は次のピークへ立ち上がる。下り切ったあとは出口の速さが種になって小さく揺れて収まり、揺れの大きさは別に置かない(settleLambda・settleOmega)。decayBez・attackBez は CSS の cubic-bezier() と同じ並びで、bezier-easing のような既製のライブラリにそのまま渡せる。
const DEG = Math.PI / 180;
const signedDelta = (a, b) => ((((a - b) + 180) % 360) + 360) % 360 - 180;
// フレーム番号 → 内側の点とリングの点の位置
function dotsAt(frame) {
const f = wrap(frame, periodFrames);
// 内側の周回体: easing なしの一定速で回る — これが通り過ぎる側
const orbitDeg = innerStartAngle + innerSpeed * f;
const ix = cx + orbitRadius * Math.sin(orbitDeg * DEG);
const iy = cy - orbitRadius * Math.cos(orbitDeg * DEG);
const ring = restAngles.map((angDeg, k) => {
// 止まらない時計: 各点は同じパルスを整数クロックぶん遅らせて読む
const tau = wrap(f - (pulseAnchor + pulseClock * k), periodFrames);
const rPre = restRadius + pulseAt(tau);
const ux = Math.sin(angDeg * DEG);
const uy = -Math.cos(angDeg * DEG);
const delta = signedDelta(orbitDeg, angDeg);
// 距離で決まる力(距離はパルス込み = フィードバック): 押し出しとすべり・二乗で弱まる
const dPre = Math.hypot(ix - (cx + rPre * ux), iy - (cy + rPre * uy));
const rAct = rPre + (pushStrength * Math.cos(delta * DEG)) / dPre ** 2;
const dAct = Math.hypot(ix - (cx + rAct * ux), iy - (cy + rAct * uy));
const slide = (-slideStrength * Math.sin(delta * DEG)) / dAct ** 2;
const effDeg = angDeg + (slide / rAct) / DEG;
return { cx: cx + rAct * Math.sin(effDeg * DEG), cy: cy - rAct * Math.cos(effDeg * DEG) };
});
return { inner: { cx: ix, cy: iy }, ring };
}ここが干渉の正体だ。リングの点は通過に反応していない — pulseAt を pulseClock ぶんずつズラして読むだけの、止まらない時計で動いている。実際、パルスと通過のズレは一周のあいだに少しずつ変わる(反応なら、ズレは一定のはずだ)。よけて見えるのは、その上に重ねた距離の力だ。近いほど強く、距離 dPre の二乗で弱まる押し出しとすべりが、点を通過点から逃がす。距離にはパルス込みの実距離を使うので、すでに外へ出た点ほど押されにくい — 非対称な逃げ方はこのフィードバックから出てくる。
画面に置くのは円を 9 つ。dotsAt が返す内側の点ひとつと、リングの点それぞれの cx・cy のとおりに描くだけだ。リングの点を先に、内側の点をあとに描けば、通り過ぎる点が上に重なる。動かすのは requestAnimationFrame で、経過した時間にコマ数を掛けてフレーム番号を出し、periodFrames で wrap して dotsAt に渡せば、このデモと同じループになる。色は決め打ちにせず、ページの地の色を受け取る。
遊ぶなら pulseClock を変える。クロックを縮めるとパルスが速く回り、よけが追いつかなくなる。pushStrength と slideStrength を上げれば逃げ方が大きくなり、innerSpeed を変えれば通過の速さが変わる。リングの点を四角に変えても、回る点と距離の力でよけるこの仕組みはそのまま使える。