# AI 꾸러미 — 느린 화면 슬로모션 없애기 (시간 조각 나누기) — Fixed timestep with substeps
> 프레임 시간을 0.05초로 잘라 버리던 것을, 0.25초까지 받아 0.05초 조각으로 여러 번 갱신해, 느린 폰에서도 게임이 슬로모션이 되지 않게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i384

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 느린 화면 슬로모션 없애기 (시간 조각 나누기) — Fixed timestep with substeps

#### 1. 목표
공 · 말이 움직이는 게임의 게임 루프를 시간 조각 방식으로 바꿔 줘 — 프레임 시간은 0.25초까지 받고, 0.05초 조각으로 나눠 갱신을 여러 번, 그리기는 한 번. 느린 폰 (12fps) 환경에서도 게임 시계가 실제 시간과 같게.

#### 2. 핵심 기술 용어
- **Fixed timestep with substeps** — 고정 조각으로 나눠 여러 번 갱신
- **Delta time clamping** — 프레임 시간 상한 — 너무 길면 자르기
- **Spiral of death** — 갱신이 밀려 점점 더 느려지는 것 (상한 0.25초로 막음)
- **Game clock vs wall clock** — 게임 시계와 실제 시간

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 프레임 시간 상한은 0.25초, 조각 크기는 0.05초 이하
- 갱신(update)만 여러 번, 그리기(render)는 한 번
- 조각마다 dt 는 그 조각 길이(마지막 조각은 남은 만큼)
- 게임 시계와 실제 시간을 나란히 보여 줘 차이가 없음을 확인

#### 5. 완성 기준 (이게 보이면 성공)
- 흉내 12fps 에서 위 칸(옛 방식)은 공이 늦게 도착하고 「60% 속도 — 슬로모션」, 아래 칸은 제시간에 도착
- 아래 칸에 「이번 프레임: 갱신 2번 · 그리기 1번」처럼 조각 수가 보인다
- fps 슬라이더를 5 ~ 60 으로 바꿔도 아래 칸 도착 시간은 2초 그대로

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 옛 방식: dt = Math.min(dt, 0.05) — 12fps(한 프레임 0.083초)면 0.033초를 버려 게임이 60% 속도로 흐른다.
- 고친 방식: 남은 시간 = min(dt, 0.25), 0.05초 이하 조각으로 나눠 update 를 여러 번 부른다.
- 그리기는 프레임에 한 번 — 갱신만 여러 번이라 비용이 크지 않다.
- 0.25초 상한은 탭을 오래 숨겼다 돌아올 때 수백 번 갱신하는 것을 막는다.

## 핵심 코드 — 0.25초까지 받아 0.05초 조각으로 갱신
(발췌: demos/demosPerf.ts demo384() stepNew() 를 게임 루프 꼴로)
```ts
let last = performance.now();

function frame(now: number): void {
  const dt = (now - last) / 1000;
  last = now;
  // 옛 방식: update(Math.min(dt, 0.05)) — 20fps 아래에서 남는 시간을 버려 슬로모션
  let rem = Math.min(dt, 0.25); // 너무 긴 프레임(탭 숨김 등)만 자른다
  let steps = 0;
  while (rem > 1e-6) {
    const s = Math.min(0.05, rem);
    update(s); // 게임 시계 · 이동 · 물리
    rem -= s;
    steps++;
  }
  render(); // 그리기는 한 번
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
```

## 흔한 실수 · 확인 목록
- [ ] **프레임 시간을 0.05초로 자르면 20fps 아래에서 게임이 느리게 흐른다** — 이 사이트 게임 대부분이 그랬다 — 실제 시간(0.25초까지) + 0.05초 조각으로 고쳤다. 새 게임은 처음부터 이 방식.
- [ ] **상한 없이 받으면 탭을 오래 숨겼다 돌아올 때 한꺼번에 수백 번 갱신한다** — 0.25초 상한을 둔다 — 그 넘은 시간은 버려도 사람이 못 느낀다.
- [ ] **update 안에서 그리기까지 하면 조각마다 그려 더 느려진다** — 갱신과 그리기를 나눠, 그리기는 루프 끝에서 한 번만.

## 완성 기준 체크리스트
- [ ] 흉내 12fps 에서 위 칸(옛 방식)은 공이 늦게 도착하고 「60% 속도 — 슬로모션」, 아래 칸은 제시간에 도착
- [ ] 아래 칸에 「이번 프레임: 갱신 2번 · 그리기 1번」처럼 조각 수가 보인다
- [ ] fps 슬라이더를 5 ~ 60 으로 바꿔도 아래 칸 도착 시간은 2초 그대로

## 이 기술 정보
- id: `i384` · 분류: 게임 시스템 · AI › 속도 기법 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 느린 프레임에서만 갱신이 2~5번으로 는다. 갱신이 무거운 게임은 조각 수를 HUD 로 확인.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i384
- 쓰면 좋을 때: 느린 폰에서 게임이 느려진다(슬로모션)는 말을 들을 때 / 이동 · 물리가 dt 에 따라 움직이는 모든 루프 — 새 게임은 처음부터
- 쓰지 말 때: 차례제 판 게임처럼 dt 를 안 쓰는 화면 — 바꿀 것이 없다 / 결정론이 꼭 필요한 물리(되감기 · 온라인 동기) — 대신 늘 같은 크기 조각 + 남은 시간 누적(accumulator)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demo384 — `src/demos/demosPerf.ts:300`
```ts
function demo384() {
  let fps = 12;
  let fix = true;
  const DUR = 2;
  let acc = 0;
  let real = 0;
  let gOld = 0;
  let gNew = 0;
  let lastSteps = 0;
  let trailO: number[] = [];
  let trailN: number[] = [];
  let hold = 0;
  const restart = (): void => {
    acc = 0;
    real = 0;
    gOld = 0;
    gNew = 0;
    trailO = [];
    trailN = [];
    hold = 0;
  };
  const stepNew = (dt: number): void => {
    if (!fix) {
      gNew += Math.min(dt, 0.05);
      lastSteps = 1;
      return;
    }
    let rem = Math.min(dt, 0.25);
    let n = 0;
    while (rem > 1e-6) {
      const s = Math.min(0.05, rem);
      gNew += s;
      rem -= s;
      n++;
    }
    lastSteps = n;
  };
  const xOf = (gt: number): number => 30 + (Math.min(gt, DUR) / DUR) * 214;
  const lane = (g: G, y: number, kind: Kind, title: string, gt: number, trail: number[], sub: string): void => {
    panel(g, 6, y, 268, 66, kind);
    tag(g, 11, y + 8, kind === 'bad' ? '끔' : '켬', kind);
    txt(g, title, 25, y + 8.3, 6.4, C.text, 'left', 700);
    const ty = y + 34;
    // 길
    g.strokeStyle = C.line;
    g.lineWidth = 5;
    g.lineCap = 'round';
    g.beginPath();
    g.moveTo(30, ty);
    g.lineTo(244, ty);
    g.stroke();
    g.lineCap = 'butt';
    for (let k = 0; k <= 4; k++) {
      const x = 30 + k * 53.5;
      txt(g, `${(k * 0.5).toFixed(1)}`, x, ty + 10, 4.8, C.faint, 'center', 600);
    }
    // 결승 깃발
    g.fillStyle = '#cfd6ee';
    g.fillRect(250, ty - 14, 1, 18);
    for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) {
      g.fillStyle = (r + c) % 2 ? '#fff' : '#20263f';
      g.fillRect(251 + c * 2.6, ty - 14 + r * 2.6, 2.6, 2.6);
    }
    // 제시간 자리(유령)
    const gx = xOf(real);
    g.strokeStyle = 'rgba(255,255,255,0.45)';
    g.setLineDash([1.6, 1.6]);
    g.lineWidth = 0.9;
    g.beginPath();
    g.arc(gx, ty, 6, 0, Math.PI * 2);
    g.stroke();
    g.setLineDash([]);
    // 지나온 자리(프레임마다 한 번씩 그린 것)
    for (let i = 0; i < trail.length; i++) {
      g.globalAlpha = 0.12 + (i / trail.length) * 0.3;
      g.fillStyle = kind === 'bad' ? C.red : C.green;
      g.beginPath();
      g.arc(xOf(trail[i]!), ty, 3.2, 0, Math.PI * 2);
      g.fill();
    }
    g.globalAlpha = 1;
    glowBall(g, xOf(gt), ty, 5, kind === 'bad' ? '#ff7a86' : '#5cf0b0');
    const pct = real > 0 ? Math.round((Math.min(gt, DUR) / Math.min(real, DUR)) * 100) : 100;
    txt(g, `게임 시계 ${Math.min(gt, 9).toFixed(2)}초 / 실제 ${real.toFixed(2)}초`, 268, y + 8.3, 5.6, C.dim, 'right', 600);
    txt(g, sub, 12, y + 58, 5.6, C.dim, 'left', 600);
    const done = gt >= DUR;
    pill(g, done ? '도착 ✓ 제시간' : `${Math.min(100, pct)}% 속도${pct < 95 ? ' — 슬로모션' : ''}`, 268, y + 58, 5.6, done || pct >= 95 ? '#1d6b52' : C.redD, done || pct >= 95 ? '#bdf7df' : '#ffc2c8', 'right');
  };
  return {
    draw(g: G, w: number, h: number, _t: number, dt: number) {
      const d = Math.min(dt, 0.05);
      if (hold > 0) {
        hold -= d;
        if (hold <= 0) restart();
      } else {
        acc += d;
        if (acc >= 1 / fps) {
          const fd = acc;
          acc = 0;
          real += fd;
          gOld += Math.min(fd, 0.05);
          stepNew(fd);
          trailO.push(gOld);
          trailN.push(gNew);
          if (trailO.length > 8) trailO.shift();
          if (trailN.length > 8) trailN.shift();
          if (real >= DUR) hold = 0.9;
        }
      }
      view(g, w, h);
      txt(g, '같은 공 · 같은 2초 길', 8, 10, 7.2, C.text, 'left', 800);
      pill(g, `흉내 ${fps} fps · 한 프레임 ${(1000 / fps).toFixed(0)}ms`, 272, 10, 6, '#2b3560', C.cyan, 'right');
      lane(g, 21, 'bad', '프레임 시간 상한 0.05초 → 그만큼만 진행', gOld, trailO, '느린 프레임에서 남는 시간을 버림');
      lane(g, 103, fix ? 'good' : 'bad', fix ? '0.25초까지 받아 0.05초 조각으로 여러 번' : '(꺼 둠) 위와 같은 옛 방식', gNew, trailN, fix ? `이번 프레임: 갱신 ${lastSteps}번 · 그리기 1번` : '느린 프레임에서 남는 시간을 버림');
      // 조각 점
      if (fix) {
        g.font = `600 5.6px ${F}`;
        const px0 = 12 + g.measureText(`이번 프레임: 갱신 ${lastSteps}번 · 그리기 1번`).width + 4;
        for (let i = 0; i < lastSteps; i++) {
          g.fillStyle = C.green;
          g.fillRect(px0 + i * 5, 159.3, 3.4, 3.4);
        }
      }
      g.restore();
    },
    controls: [
      { type: 'range', label: '흉내 fps', min: 5, max: 60, step: 1, value: fps, on: (v) => { fps = v; restart(); } },
      { type: 'toggle', label: '시간 조각 나누기 (아래 칸)', value: fix, on: (v) => { fix = v; restart(); } },
      { type: 'button', label: '다시', on: () => restart() },
    ] as Control[],
  };
}
```

### i384 견본 항목 — `src/demos/demosPerf.ts:1611`
```ts
  i384: { kind: '2d', caption: '흉내 12fps — 위(옛 방식)는 공이 느려져 슬로모션, 아래(시간 조각)는 제시간에 도착', make: demo384 }
```

## 관련 기술
- 다음에 해 볼 기술: [긴 작업 찾기 (longtask · 프레임 그래프)](https://ai-techstudio.web.app/ai/t/i394.md) `i394` · [무거운 표 미리 · 나눠 만들기](https://ai-techstudio.web.app/ai/t/i387.md) `i387`
- 참고 문서: [Gaffer On Games — Fix Your Timestep!](https://gafferongames.com/post/fix_your_timestep/)
