GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

이중 진자 시뮬레이션 9가지 — 오차 하나가 길을 가르는 카오스 장면

출발 각도를 100만분의 1 라디안만 바꿔도 진자 끝은 전혀 다른 길을 그린다 — 03번은 두 진자를 겹쳐 그 갈림길을 눈앞에 보여 준다. 아래 아홉 장면은 이중 진자를 캔버스 2d 위에서 프레임마다 계산해 그린 것이고, 파일을 내려받으면 그대로 켜진다.

이중 진자 아홉 장면을 화면에서 바로 돌려 본다

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

이중 진자 시뮬레이션 9가지 — 적분 한 단계가 카오스의 모양을 정한다

아홉 장면의 뿌리는 같은 계산 한 벌이다. 줄 하나에 줄이 하나 더 매달린 두 팔의 각도를 accTheta1·accTheta2 라는 식 둘로 가속도로 바꾸고, rk4Step 이 룽게-쿠타 네 단계로 한 걸음씩 앞으로 보낸다. 맨 끝에 달린 공을 이 글에서는 끝 추라 부른다. 팔 길이 L1·L2 와 질량 M1·M2 는 전부 1.0, 중력 G 는 9.81 이고, 각도는 아래 방향이 0 이다. 난수도 Date.now 도 쓰지 않아서 같은 출발 각도에서는 언제나 같은 길이 나온다.

01 이 그 뼈대를 그대로 보이고, 02 는 진자 끝이 지나온 길을, 04 는 두 각도를 한 장의 평면에 그린다. 03·06 은 출발 각도를 아주 조금씩 다르게 한 진자를 여럿 겹치고, 05 는 계산법을 둘 비교하며, 07 은 사람이 출발 각도를 정한다. 08 은 출발 각도 전체를 지도로 칠하고, 09 는 이 모두를 책상 위 황동 장난감 한 장면으로 묶는다.

이 장면들은 화면에서 실시간보다 빠르게 흐른다. 프레임마다 SUBSTEPS 걸음을 DT 0.01 초씩 진행하므로 프레임당 0.03초(03 은 0.04초)이고, 고정 시간 누적기가 16 밀리초마다 한 프레임을 내므로 실시간의 약 2배(03 은 약 2.5배) 속도다. 라이브러리·WebGL·외부 주소는 하나도 쓰지 않았다. 비슷한 물리 장면이 궁금하면 바운싱 볼 애니메이션 9가지 — 충돌·중력 물리 계산과 팽이 돌리기 9가지 — 각속도와 세차로 계산하는 회전 장면을 같이 보면 된다.

기법별 코드로 보는 진자 계산

아래 조각은 데모 소스에서 연속된 줄을 순서 그대로 잘라 붙였다. 공통 식(accTheta1·accTheta2·rk4Step)은 아홉 장면이 같아서 01 에서 한 번만 쓰고 나머지에서는 장면마다 다른 줄을 골랐다. 완결된 파일은 압축 파일에 들어 있다.

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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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) 제곱을 알파와 굵기에 곱해 오래된 선일수록 가늘고 투명하게 하고 색상은 속도 sp 가 큰 구간일수록 더 많이 옮겨 간다. 150프레임을 미리 돌려 두므로 켜는 순간 이미 궤적이 쌓여 있다. 누적면적 62.4%·평균 강도 31.4·23/23 프레임이다.

끝 추가 궤적이 잉크처럼 옅어지는 흔적
새 창에서 열기

02 · 끝 추가 궤적이 잉크처럼 옅어지는 흔적 — 측정 누적면적 62.405% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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)씩 진행하므로 화면에서는 켠 뒤 곧 갈라지고, SIM_RESET_FRAMES 420 프레임에서 처음부터 다시 한다. 누적면적 62.9%·평균 강도 36.3·23/23 프레임이다.

머리카락 한 올 차이로 갈라서는 쌍둥이 진자
새 창에서 열기

03 · 머리카락 한 올 차이로 갈라서는 쌍둥이 진자 — 측정 누적면적 62.895% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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) 알파의 어두운 사각형을 덮어 오래된 선을 지우고, 선 색상은 경과 시간(밀리초)을 14 로 나눈 만큼 돌아간다. 면적은 이 편에서 가장 작은 32.4%이지만 평균 강도는 54.5 로 높고 23/23 프레임이다.

두 각도로 그리는 위상 평면 궤적
새 창에서 열기

04 · 두 각도로 그리는 위상 평면 궤적 — 측정 누적면적 32.436% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 점이 따라붙고 SIM_RESET_FRAMES 340 프레임에서 다시 시작한다. 누적면적 62.5%·평균 강도 38.6·23/23 프레임이다.

열여섯 진자가 부채꼴로 퍼지는 색 띠
새 창에서 열기

06 · 열여섯 진자가 부채꼴로 퍼지는 색 띠 — 측정 누적면적 62.547% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 와 Enter 가 놓기와 잡기를 오간다. 아무도 만지지 않으면 DEMO_CYCLE_MS 4800 마다 DEMO_POSES 네 자세 중 하나를 가짜 포인터로 끌어 놓고 사람이 입력하면 시연을 멈춘다. 누적면적 61.8%·평균 강도 43.6·23/23 프레임이다.

팔을 끌어다 놓아 출발 각도를 정하는 진자
새 창에서 열기

07 · 팔을 끌어다 놓아 출발 각도를 정하는 진자 — 측정 누적면적 61.799% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 의 포인터 좌표도 캔버스 크기 대비 비율로 이 좌표에 환산한다. 아홉 장면의 폰 폭 넘침 측정은 모두 없음이었다. 값을 직접 바꿔 보려고 압축 파일을 풀 때는 비밀번호 jep9f6eb 을 넣는다.

변형 세 가지와 접근성

이름 값 느낌
팔 길이 비율 바꾸기 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 만큼 미리 돌려 그리고, 버튼을 누르거나 캔버스에서 Enter·Space 를 누르면 40프레임을 한꺼번에 진행해 다시 그리며 상태 줄(aria-live="polite")이 「한 장면씩 넘기는 중」 으로 알린다. 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종 — 나비 모양 카오스 궤적을 미적분 한 걸음씩 계산해 쌓는 캔버스에 있다. 쓰인 그리기 함수는 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% 불어난다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.