GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

二重振り子 シミュレーション 9パターン — 誤差ひとつが道を分けるカオス

出発角度を百万分の一ラジアン変えるだけで先端はまったく別の道を描く — 03番は二つの振り子を重ねてその分かれ道を目の前で見せる。以下の九つの二重振り子はキャンバス2dでフレームごとに計算して描いたもので、ファイルをダウンロードすればそのまま動きます。

二重振り子の九つの場面をこのページで動かして確かめる

下のグリッドのキャンバスは、ページを読み込んだ時点から動いています。九つとも「支点から一本目の腕が下がり、その先にもう一本の腕が下がる」という同じ形から始まります。二本目の腕の先についた重りを、この記事では「先端」と呼びます。人が触って出発角度を決められるのは07番だけで、ポインターか矢印キーを使います。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

二重振り子 シミュレーション 9パターン — 積分の一段が混沌の形を決める

九つの場面の根は同じ計算一式です。二本の腕の角度を accTheta1・accTheta2 という二つの式で加速度に直し、rk4Step がルンゲ=クッタ法(RK4)の四段階で一歩ずつ先へ進めます。二重振り子 運動方程式を読み解く必要はなく、この二つの式をそのまま呼ぶだけで済みます。腕の長さ L1・L2 と質量 M1・M2 はすべて1.0、重力 G は9.81で、角度は真下を0とします。乱数も時計関数も使わないので、同じ出発角度なら毎回まったく同じ道になります。

01は骨組みをそのまま見せ、02は先端が通ってきた道、04は二つの角度を一枚の平面に描きます。03と06は出発角度をほんの少しずつずらした振り子を何本も重ね、05は計算法を二つ並べて比べ、07は人が出発角度を決めます。08は出発角度の全体を地図として塗り、09はそれらを机の上の真鍮のおもちゃ一つにまとめます。

振り子が動く場面は画面の中で実時間より速く進みます。毎フレーム SUBSTEPS 歩を DT 0.01秒ずつ進めるのでフレームあたり0.03秒(03は SUBSTEPS が4で0.04秒)で、固定時間のアキュムレーターが16ミリ秒ごとに一フレームを出すため、実時間のおよそ2倍(03は約2.5倍)の速さです。ライブラリ・WebGL・外部アドレスは一つも使っていません。近い物理の場面は跳ねるボール アニメーション 9選 — 衝突と重力の物理計算とコマ 回転 9パターン — 角速度と歳差で描く回転シーンにあります。

技法別に見る振り子計算のコード

以下の断片はデモのソースから連続した行を順番どおりに切り出したものです。共通の式(accTheta1・accTheta2・rk4Step)は九つとも同じなので01で一度だけ載せ、残りは場面ごとに違う行を選びました。完結したファイルは暗号化zipに入っています。

01二本の腕の角加速度を解いて一歩ずつ進む振り子

stepFrame() がフレームごとに SUBSTEPS 回 rk4Step(sim, DT) を呼び、歩き終えた位置で accTheta1・accTheta2 を呼び直して二つの加速度(accA・accB)を得ます。上の二本の棒はこの加速度を±90 rad/s²の目盛りで見せ、先端には72点の軌跡がつきます。この場面は累積面積62.4%・平均強度34.1・23/23フレームです。

二本の腕の角加速度を解いて一歩ずつ進む振り子
新しいタブで開く

01 · 二本の腕の角加速度を解いて一歩ずつ進む振り子 — 計測 累積面積 62.437% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DTSUBSTEPSrk4StepaccTheta1accTheta2
      var sim = [START_T1, START_T2, 0, 0];
      var accA = 0, accB = 0;
      var trail = [];
      function stepFrame() {
          for (var i = 0; i < SUBSTEPS; i += 1) {
              rk4Step(sim, DT);
          }
          accA = accTheta1(sim[0], sim[1], sim[2], sim[3]);
          accB = accTheta2(sim[0], sim[1], sim[2], sim[3]);
          var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
          pushTrail(trail, p.x, p.y, TRAIL_LEN);
      }

02インクのように薄れていく先端の軌跡

歩ごとに先端の位置と速さ sp を ink 配列に積み、TRAIL_MAX の560点を超えると古い点から捨て、描くときは線分ごとに (i / ink.length) を INK_FADE_POWER(1.6)乗してアルファと太さに掛けるので、古い線ほど細く透明になります。150フレームを先に回しておくため、表示した瞬間にもう軌跡が積もっています。累積面積62.4%・平均強度31.4・23/23フレームです。

先端の軌跡がインクのように薄れる跡
新しいタブで開く

02 · 先端の軌跡がインクのように薄れる跡 — 計測 累積面積 62.405% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TRAIL_MAXINK_FADE_POWERL2rk4StepbobTwo
      function stepFrame() {
          for (var i = 0; i < SUBSTEPS; i += 1) {
              rk4Step(sim, DT);
              var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
              var sp = Math.abs(sim[3]) + Math.abs(sim[2]) * 0.5;
              ink.push({ x: p.x, y: p.y, sp: sp });
              if (ink.length > TRAIL_MAX) {
                  ink.shift();
              }
          }
          var q = bobOne(sim, PIVOT_X, PIVOT_Y);
          pushTrail(armTrail, q.x, q.y, 90);
      }
          ctx.lineCap = 'round';
          for (var i = 1; i < ink.length; i += 1) {
              var f = Math.pow(i / ink.length, INK_FADE_POWER);
              ctx.beginPath();
              ctx.moveTo(ink[i - 1].x, ink[i - 1].y);
              ctx.lineTo(ink[i].x, ink[i].y);
              ctx.strokeStyle = hsl(250 + ink[i].sp * 7 + i * 0.12, 78, 80, 0.9 * f);
              ctx.lineWidth = 0.5 + 3.4 * f;
              ctx.stroke();
          }

03髪の毛一本の差で分かれていく双子の振り子

restart() が振り子Bの一本目の角度にだけ OFFSET 1e-6を足して出発させ、advance() が二つを同じ歩数で進めたうえで先端どうしの距離 sepNow を UNIT 単位で積み、対数目盛りのグラフ(logLevel)に描きます。距離が SPLIT_DIST の0.1を超えたフレームで、状態行が「二つの振り子が分かれた」に変わります。同じ式で計算するとその時刻はシミュレーション8.47秒ですが、開いてすぐ見せるために PREROLL_FRAMES の150フレーム(6秒分)を先に回してグラフに積み、フレームあたり0.04秒(SUBSTEPS 4)ずつ進めるので、画面では開いてまもなく分かれ、420フレームでまた最初からやり直します。累積面積62.9%・平均強度36.3・23/23フレームです。

髪の毛一本の差で分かれる双子の振り子
新しいタブで開く

03 · 髪の毛一本の差で分かれる双子の振り子 — 計測 累積面積 62.895% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

OFFSETSPLIT_DISTSIM_RESET_FRAMESPREROLL_FRAMESlogLevel
      function advance() {
          for (var i = 0; i < SUBSTEPS; i += 1) {
              rk4Step(simA, DT);
              rk4Step(simB, DT);
          }
          var pa = bobTwo(simA, PIVOT_X, PIVOT_Y), pb = bobTwo(simB, PIVOT_X, PIVOT_Y);
          pushTrail(trailA, pa.x, pa.y, TRAIL_LEN);
          pushTrail(trailB, pb.x, pb.y, TRAIL_LEN);
          sepNow = Math.hypot(pa.x - pb.x, pa.y - pb.y) / UNIT;
          sepHist.push(sepNow);
          frameCount += 1;
          if (splitFrame < 0 && sepNow > SPLIT_DIST) {
              splitFrame = frameCount;
              setStatus('二つの振り子が分かれた');
          }
      }
      function restart() {
          simA = [START_T1, START_T2, 0, 0];
          simB = [START_T1 + OFFSET, START_T2, 0, 0];
          trailA = [];
          trailB = [];
          sepHist = [];
          frameCount = 0;
          splitFrame = -1;
          setStatus('デモ実行中');
          for (var i = 0; i < PREROLL_FRAMES; i += 1) {
              advance();
          }
      }

04二つの角度で描く位相平面の軌跡

一本目の角度を横の planeX、二本目の角度を縦の planeY に置いて歩ごとに直前の点と結びます。wrapAngle が角度を-πからπに巻き込むので、画面の端を飛び越える線分(差が PLANE_SIZE の半分以上)は引きません。見えないオフスクリーンキャンバスに毎フレーム FADE_ALPHA(0.012)の暗い四角を重ねて古い線を消し、線の色は elapsed / 14 度ずつ回ります。面積はこの編で最小の32.4%ですが平均強度は54.5と高く、23/23フレームです。

二つの角度で描く位相平面の軌跡
新しいタブで開く

04 · 二つの角度で描く位相平面の軌跡 — 計測 累積面積 32.436% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FADE_ALPHAPLANE_SIZEwrapAngleplaneXplaneY
      function wrapAngle(a) {
          var r = (a + PI) % (2 * PI);
          if (r < 0) {
              r += 2 * PI;
          }
          return r - PI;
      }
      function planeX(a) { return (wrapAngle(a) + PI) / (2 * PI) * PLANE_SIZE; }
      function planeY(a) { return (PI - wrapAngle(a)) / (2 * PI) * PLANE_SIZE; }
      function stepFrame() {
          pg.fillStyle = 'rgba(29,26,43,' + FADE_ALPHA + ')';
          pg.fillRect(0, 0, PLANE_SIZE, PLANE_SIZE);
          pg.lineWidth = 2.4;
          pg.lineCap = 'round';
          for (var i = 0; i < SUBSTEPS; i += 1) {
              rk4Step(sim, DT);
              var px = planeX(sim[0]), py = planeY(sim[1]);
              if (hasLast && Math.abs(px - lastPx) < PLANE_SIZE / 2 && Math.abs(py - lastPy) < PLANE_SIZE / 2) {
                  pg.beginPath();
                  pg.moveTo(lastPx, lastPy);
                  pg.lineTo(px, py);
                  pg.strokeStyle = hsl(250 + elapsed / 14, 80, 78, 0.95);
                  pg.stroke();
              }
              lastPx = px;
              lastPy = py;
              hasLast = true;
          }
          var q = bobTwo(sim, PIVOT_X, PIVOT_Y);
          pushTrail(armTrail, q.x, q.y, 50);
      }

05計算法ごとのエネルギーのずれを測る比較メーター

同じ出発から rk4Step と eulerStep(今の傾きで一度だけ進める方法)を並べて回し、totalEnergy が最初の値から何%ずれたか(drift)を棒と数字で見せます。オイラー法は最初の1秒ですでに+20.4%、3秒で+61%、5秒で+98%、10秒で+217%と膨らみ、RK4は10秒でも±1e-4%以内なので画面には「-0.000%」と出ます。オイラー法が EULER_LIMIT_PCT の240を超えるか SIM_RESET_FRAMES の330フレームに届くと restart() で巻き戻し、累積面積53.6%・平均強度39.4・23/23フレームです。

計算法ごとのエネルギーのずれを測る比較メーター
新しいタブで開く

05 · 計算法ごとのエネルギーのずれを測る比較メーター — 計測 累積面積 53.567% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

EULER_LIMIT_PCTBAR_FULL_PCTeulerSteptotalEnergydrift
      function eulerStep(s, h) {
          var a1 = accTheta1(s[0], s[1], s[2], s[3]);
          var a2 = accTheta2(s[0], s[1], s[2], s[3]);
          s[0] += h * s[2];
          s[1] += h * s[3];
          s[2] += h * a1;
          s[3] += h * a2;
      }
      // 全エネルギー = 運動 + 位置 (真下が基準0になるように高さを測る)
      function totalEnergy(s) {
          var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
          var kinetic = 0.5 * (M1 + M2) * L1 * L1 * w1 * w1 + 0.5 * M2 * L2 * L2 * w2 * w2
              + M2 * L1 * L2 * w1 * w2 * Math.cos(t1 - t2);
          var potential = -(M1 + M2) * G * L1 * Math.cos(t1) - M2 * G * L2 * Math.cos(t2);
          return kinetic + potential;
      }

06十六本の振り子が扇形に広がる色の帯

COUNT の16本の振り子が、一本目の角度だけを OFFSET_STEP の0.004ずつずらして(最後は最初より0.06ラジアン多く傾けて)出発します。hueOf(i) が番号に比例して色相を330度から HUE_SPAN の300度ぶん回し、一点から出た色の帯が散っていきます。振り子ごとに先端の軌跡 TRAIL_LEN の26点がつき、340フレームでやり直します。累積面積62.5%・平均強度38.6・23/23フレームです。

十六の振り子が扇形に広がる色の帯
新しいタブで開く

06 · 十六の振り子が扇形に広がる色の帯 — 計測 累積面積 62.547% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

COUNTOFFSET_STEPHUE_SPANTRAIL_LENhueOf
      function restart() {
          fan = [];
          trails = [];
          for (var i = 0; i < COUNT; i += 1) {
              fan.push([START_T1 + OFFSET_STEP * i, START_T2, 0, 0]);
              trails.push([]);
          }
          frameCount = 0;
      }
      restart();
      function stepFrame() {
          for (var i = 0; i < COUNT; i += 1) {
              for (var k = 0; k < SUBSTEPS; k += 1) {
                  rk4Step(fan[i], DT);
              }
              var p = bobTwo(fan[i], PIVOT_X, PIVOT_Y);
              pushTrail(trails[i], p.x, p.y, TRAIL_LEN);
          }
          frameCount += 1;
          if (frameCount >= SIM_RESET_FRAMES) {
              restart();
          }
      }
      function hueOf(i) { return 330 + HUE_SPAN * i / (COUNT - 1); }

07腕をドラッグして出発角度を決める振り子

pointerdown が近い腕の先端をつかむと(GRAB_RADIUS 22、最大1.6倍の距離まで許容)held が立って stepFrame() が積分を飛ばし、dragTo がポインター位置から Math.atan2 で角度を求めて速度を0にします。離した瞬間からその角度で再出発し、キーボードでは←→が一本目、↑↓が二本目の角度を KEY_ANGLE_STEP の0.12ずつ変え、Spaceで離す・つかむを切り替えます。誰も触らないと DEMO_CYCLE_MS の4800ごとに DEMO_POSES の四つの姿勢から一つを疑似ポインターでドラッグし、人が入力すると実演を止めます。累積面積61.8%・平均強度43.6・23/23フレームです。

腕をドラッグして出発角度を決める振り子
新しいタブで開く

07 · 腕をドラッグして出発角度を決める振り子 — 計測 累積面積 61.799% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GRAB_RADIUSKEY_ANGLE_STEPPATH_STEPSDEMO_CYCLE_MSdragTo
      function stepFrame() {
          if (demo) {
              stepDemo();
          }
          if (held) {
              return;
          }
          for (var i = 0; i < SUBSTEPS; i += 1) {
              rk4Step(sim, DT);
          }
          var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
          pushTrail(trail, p.x, p.y, TRAIL_LEN);
      }
      function dragTo(x, y) {
          if (grab === 1) {
              sim[0] = Math.atan2(x - PIVOT_X, y - PIVOT_Y);
          }
          else if (grab === 2) {
              var a = bobOne(sim, PIVOT_X, PIVOT_Y);
              sim[1] = Math.atan2(x - a.x, y - a.y);
          }
          sim[2] = 0;
          sim[3] = 0;
          trail = [];
          if (reduce) {
              makePreview();
          }
      }

08裏返るまでの時間を色で塗った出発角の地図

GRID 48の一マスが出発角度の一組で、flipTime(t1, t2) が静止状態から出発した振り子を FLIP_DT の0.02秒ずつ FLIP_STEPS の500歩(10秒)回し、二本目の角度が初めてπを超える時刻を返します(最後まで超えなければ-1)。flipColor がその時刻を対数目盛りの五色で塗り、裏返らないマスには暗いインク色を使います。一フレームに CELLS_PER_FRAME の36マスずつ塗って2304マスを約64フレームで埋め、中央の暗い塊はエネルギーが足りず裏返らない領域で、マスを押すと右側でその出発の振り子が実際に動きます。累積面積43.2%・平均強度57.0・23/23フレームです。

ひっくり返るまでの時間で塗った出発角マップ
新しいタブで開く

08 · ひっくり返るまでの時間で塗った出発角マップ — 計測 累積面積 43.201% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GRIDCELLS_PER_FRAMEFLIP_DTflipTimeflipColor
      function startOf(i) { return -PI + (i + 0.5) * 2 * PI / GRID; }
      function startOfRow(j) { return PI - (j + 0.5) * 2 * PI / GRID; }
      function flipTime(t1, t2) {
          var s = [t1, t2, 0, 0];
          for (var n = 0; n < FLIP_STEPS; n += 1) {
              rk4Step(s, FLIP_DT);
              if (Math.abs(s[1]) > PI) {
                  return (n + 1) * FLIP_DT;
              }
          }
          return -1;
      }
      function flipColor(t) {
          if (t < 0) {
              return [42, 36, 66];
          }
          var u = clamp(Math.log(1 + t * 3) / Math.log(1 + FLIP_STEPS * FLIP_DT * 3), 0, 0.999) * (STOPS.length - 1);
          var k = Math.floor(u), f = u - k;
          var c0 = STOPS[k], c1 = STOPS[k + 1];
          return [c0[0] + (c1[0] - c0[0]) * f, c0[1] + (c1[1] - c0[1]) * f, c0[2] + (c1[2] - c0[2]) * f];
      }

09真鍮の机上おもちゃに光る軌跡を残すヒーロー

brassSeg が暗い輪郭・真鍮の本体・ハイライトの三重で腕を描き、brassBall が放射状グラデーションで球を描きます。shadowOf は関節の机からの高さ h = DESK_Y - p.y に比例して影を横へ SHADOW_SKEW(0.42)×h だけずらし、高いほど大きく淡く描きます。先端の軌跡は lighter 合成で GLOW_TRAIL_MAX の300点まで重なって光り、累積面積48.5%・平均強度40.5・23/23フレームです。

真鍮の卓上トイに光る軌跡を残すヒーロー
新しいタブで開く

09 · 真鍮の卓上トイに光る軌跡を残すヒーロー — 計測 累積面積 48.535% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GLOW_TRAIL_MAXSHADOW_SKEWDESK_YshadowOfbrassBall
      function shadowOf(p, r) {
          var h = clamp(DESK_Y - p.y, 0, 200);
          var sx = p.x + h * SHADOW_SKEW;
          var sy = DESK_Y + 8 + h * 0.07;
          var k = 1 + h * 0.012;
          ctx.save();
          ctx.translate(sx, sy);
          ctx.scale(1, 0.28);
          ctx.beginPath();
          ctx.arc(0, 0, r * k, 0, 6.2832);
          ctx.fillStyle = 'rgba(8,6,16,' + (0.42 / k) + ')';
          ctx.fill();
          ctx.restore();
      }
      function brassSeg(x1, y1, x2, y2, w) {
          seg(x1, y1, x2, y2, w + 2, '#6f5630');
          seg(x1, y1, x2, y2, w, '#d4b077');
          var dx = x2 - x1, dy = y2 - y1;
          var len = Math.hypot(dx, dy) || 1;
          var nx = -dy / len, ny = dx / len;
          seg(x1 + nx * w * 0.22, y1 + ny * w * 0.22, x2 + nx * w * 0.22, y2 + ny * w * 0.22, w * 0.3, '#f6e7c1');
      }

値が場面を変える場所 — 腕の長さ・質量・時間の刻み

場面の印象を大きく変える値は三つです。腕の長さ L1・L2 は先端が描く道の大きさと速さを、時間の刻み DT(とフレームあたりの歩数 SUBSTEPS)は計算が実際の運動からずれる度合いを、03の OFFSET は二つの振り子が分かれる時刻を決めます。質量 M1・M2 はこの編ではすべて1.0のままにしてあり、変えて測ってはいません。

場面ごとの測定値を表にまとめました。03は先端が二つ広い軌跡を残しグラフまで動くので最も広く、04は絵が192計算ピクセルの正方形の平面の中にしか描かれないので最も狭くなります。どの場面も23フレームすべてで動きました。

項目 累積面積 平均強度 動いたフレーム
01 RK4の基本振り子 62.4% 34.1 23/23
02 インクの軌跡 62.4% 31.4 23/23
03 双子の発散 62.9% 36.3 23/23
04 位相平面 32.4% 54.5 23/23
05 エネルギーメーター 53.6% 39.4 23/23
06 十六本の扇 62.5% 38.6 23/23
07 ドラッグで出発 61.8% 43.6 23/23
08 裏返りの地図 43.2% 57.0 23/23
09 真鍮のヒーロー 48.5% 40.5 23/23

どこで壊れるか — 大きな刻み・エネルギー誤差の蓄積・狭い画面

最初に壊れるのは時間の刻みです。01の DT を0.01から0.05に大きくすると、20秒間でRK4のエネルギー誤差が最大0.03%から13.5%に増え、0.1なら140.7%に達します。フレームあたりの歩数は同じなので場面が五倍速く流れることにもなり、速くしようと値を上げた結果、計算が狂った振り子を見ることになります。計算法が粗い05のオイラー法は、同じ DT でも最初の1秒ですでに+20%ずれます。

画面が狭くなっても計算は変わりません。キャンバスはCSSで比率だけ縮み、計算座標は CALC_W の320・CALC_H の200に固定で、07のポインター座標もキャンバスの大きさに対する比率でこの座標に換算します。九つの場面のスマホ幅のはみ出し測定は、すべてなしでした。値を自分で変えて試すときは、zipを展開するのにパスワードjep9f6ebを入力してください。

変形3つとアクセシビリティ

名前 値 感じ
腕の長さの比を変える 02 L2 1.0 → 0.6 二本目の腕を1.0から0.6に縮めると、15秒のシミュレーションで二本目の最大角速度が13.6から20.7 rad/sに上がって先端が速く回り、通った道の長さは86から77に減って狭い場所に密に積もる。
時間の刻みを大きくする 01 DT 0.01 → 0.05 時間の刻みを0.05に大きくすると、同じ20秒でルンゲ・クッタのエネルギー誤差が最大0.03%から13.5%に増えて運動が実際とずれ、フレームごとの歩数は同じなので場面も五倍速く流れる。
出発の差の大きさを調整する 03 OFFSET 1e-6 → 1e-3 出発の差を1e-3に大きくすると、二つの先端の間の距離が0.1を超える時刻がシミュレーション8.5秒から4.0秒に早まり、分かれる瞬間がほぼ半分の時間で来る。

prefers-reduced-motion がオンのときは、九つとも requestAnimationFrame のループを始めません。01〜06と09は、場面ごとに値の違う PREROLL_FRAMES(30〜150フレーム)の分だけ先に回した静止画を一枚すぐに描き、ボタン・キャンバスのクリック・Enter・Spaceを一度押すと40フレームをまとめて進めて描き直します。このときは状態行(aria-live="polite")が「一場面ずつ送っています」と知らせます。07と08は onButton を別の動きに置き換えているので、この40フレーム送りはありません。07は積分せず、ドラッグやキー入力のたびに150歩ぶんの先端の経路を点線ですぐ描き、08は2304マスを開始時に一度で計算します。キャンバスは tabindex が0で、ボタンは button 要素なので、キーボードだけで操作できます。

二重振り子はなぜ予測できないのか?

このコードでは式が決定的なので、出発角度がまったく同じなら道もまったく同じです。予測できないとは出発角度を完全には知れないという意味で、その小さな差が歩ごとに掛け算されていくからです。03は二重振り子 カオスのこの性質を数字で見せます。二つの先端の距離は1秒で1.8e-6、5秒で4.7e-4、7秒で1.5e-2、9秒で約1.2と、2〜4秒ごとに30倍以上ずつ大きくなり、最初の百万分の一の差が腕一本ぶんの差になります。08の地図で色の境目が複雑に入り組むのも同じ現象で、出発がわずかに違うだけで裏返る時刻が変わるからです。

だから二重振り子の運動を遠い未来まで正確に描くには、問題になるのは計算ではなく出発値です。同じキャンバスの方式でピクセルまで扱う場面はhtml5 canvas アニメーション9種 — 水滴とブラックホールに、ローレンツ系のカオスはローレンツアトラクター 9種 — 蝶の形のカオス軌道を1歩ずつ数値積分して積むにあります。使った描画関数はCanvas APIのドキュメントとrequestAnimationFrameのドキュメントで読めます。

二重振り子の場面を使う前によくある質問

二重振り子の作り方を自分のページで試すには、まず何を変えますか?

07を開いて出発角度をドラッグで決めてみて、コードでは START_T1・START_T2 を変えれば出発角度が変わります。分かれる時刻を見たいなら、03の OFFSET を1e-6から1e-3に大きくしてみてください。距離が0.1を超える時刻が8.47秒から4.04秒に早まります。

外部のライブラリや画像は必要ですか?

必要ありません。九つの場面はすべてキャンバス2dと一つのファイルのスクリプトだけで動き、外部アドレスを呼ぶ行も乱数関数もないので、コピーして貼ればすぐ同じ絵が出ます。

振り子が急に跳ねたりエネルギーが増えたりしたらどこを直しますか?

DT を小さくするか SUBSTEPS を増やして、一歩の大きさを小さくします。01の変形で見たとおり、DT 0.05ではエネルギー誤差が最大13.5%、0.01では0.03%でした。計算法も確認してください。05のようにオイラー法に変えると、同じ DT でもエネルギーは最初の1秒ですでに+20%膨らみます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。