# AI 꾸러미 — 바뀔 때만 그리기 (멈춘 화면은 쉬기) — Render on demand (dirty checking)
> 그리기 직전 화면에 영향을 주는 값(물체 자리 · 색 · 빛 · 카메라 · 크기)을 모두 적어 지난번과 맞대 보고, 같으면 그리지 않아 멈춘 화면에서 배터리 · 열을 줄인다.  
> 견본: https://ai-techstudio.web.app/#t/u81

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

## 주문서

### 만들어 줘: 바뀔 때만 그리기 (멈춘 화면은 쉬기) — Render on demand (dirty checking)

#### 1. 목표
3D 보드게임 (생각하는 시간이 긴)에 바뀔 때만 그리기를 넣어 줘 — 매 프레임 렌더 전에 화면 크기 · 톤 매핑 · 카메라 행렬 · 보이는 물체의 행렬 · 재질 색 · 빛을 숫자 배열에 적고 지난번과 비교해, 같으면 renderer.render 를 건너뛰기. 그림자 지도도 그림자에 영향 있는 것이 바뀔 때만. 배터리 아끼기.

#### 2. 핵심 기술 용어
- **Render on demand (dirty checking)** — 바뀔 때만 그리기
- **FrameGate** — 이 사이트의 「그릴까 말까」 문 (shared/frameGate.ts)
- **shadowMap.autoUpdate / needsUpdate** — 그림자 지도도 바뀔 때만 다시
- **Float64Array snapshot compare** — 값을 숫자 배열로 적어 하나하나 비교

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 화면을 바꿀 수 있는 값은 빠짐없이 — 행렬 · 색 · 투명도 · 텍스처 · 빛 · 카메라 · 화면 크기 · 셰이더 uniform
- 해시가 아니라 값 자체를 비교 (바뀐 것을 놓치지 않게), 소수점 흔들림 1e-7 은 같은 것으로
- 한 프레임에 여러 장면을 그리면 하나라도 바뀌면 모두 그리기
- setSize · webglcontextrestored · 캔버스를 베낄 때는 invalidate
- 그린 프레임 수를 띠로 보여 줘 멈춘 동안 0 인지 확인

#### 5. 완성 기준 (이게 보이면 성공)
- 말이 움직일 때만 아래 줄(FrameGate)에 그린 프레임 칸이 켜지고, 가만히 있을 땐 꺼진다
- 위 줄(늘 그리기)은 내내 켜져 있다 — 「최근 60프레임 중 그린 수」 비교
- 창 크기를 바꾸거나 다른 탭에서 돌아와도 화면이 비지 않는다

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

## 원리
- 보드게임은 대부분 생각하는 시간이라 장면이 멈춰 있다 — 그때도 매 프레임 판 · 그림자를 그리면 폰이 뜨거워진다.
- 그리기 직전에 화면을 바꿀 수 있는 값을 빠짐없이 Float64Array 에 적는다: 캔버스 크기 · 픽셀 비율 · 톤 매핑 · 지우는 색 · 카메라 행렬 · 보이는 물체 행렬 · 재질 값 · 텍스처 · 빛 · 셰이더 uniform.
- 지난번 배열과 숫자 하나하나 비교(상대 오차 1e-7 까지는 같음) — 같으면 그리지 않는다. 캔버스는 지난 그림을 그대로 보여 준다.
- 그림자는 따로 적어, 말 · 판이 그대로면 shadowMap.needsUpdate = false (빛나는 표시만 깜빡일 때).
- setSize · 문맥 복구 때는 캔버스가 지워지므로 invalidate() 로 다음엔 무조건 그린다.

## 핵심 코드 — FrameGate 쓰기 + 핵심 비교
(발췌: src/game/games/shared/frameGate.ts FrameGate.changed() 를 줄임)
```ts
import { FrameGate } from '../shared/frameGate';

const gate = new FrameGate();
function loop(): void {
  update();
  // 바뀌었을 때만 그린다 — 같으면 캔버스는 지난 그림 그대로
  if (gate.changed(renderer, camera, [scene])) renderer.render(scene, camera);
  requestAnimationFrame(loop);
}

/* changed() 안에서 하는 일 (줄임):
   n = 0;
   w(canvas.width); w(canvas.height); w(renderer.getPixelRatio()); w(renderer.toneMapping); w(renderer.toneMappingExposure);
   color(renderer.getClearColor(tmp));
   camera.updateMatrixWorld(); mat(camera.matrixWorld); mat(camera.projectionMatrix);
   for (const root of roots) { root.updateMatrixWorld(); root.traverseVisible(o => object(o)); } // 행렬 · 재질 · 빛 · uniform
   if (n === prev.length && near(prev, cur, n)) return false;   // 같으면 그리지 않음
   prev = cur.slice(0, n);
   renderer.shadowMap.autoUpdate = false;
   renderer.shadowMap.needsUpdate = !shadowSame;                // 그림자도 바뀔 때만
   return true; */

// 상대 오차 1e-7 까지는 같은 값 (매 프레임 다시 계산하는 회전의 끝자리 흔들림)
function near(a: Float64Array, b: Float64Array, n: number): boolean {
  for (let i = 0; i < n; i += 1) {
    const x = a[i], y = b[i];
    if (x !== y && Math.abs(x - y) > 1e-7 * (1 + Math.abs(x))) return false;
  }
  return true;
}
```

## 흔한 실수 · 확인 목록
- [ ] **setSize 를 부르면 크기가 같아도 캔버스가 지워져 빈 화면이 남는다** — FrameGate 는 renderer.setSize 를 감싸 부를 때마다 invalidate 한다 (문맥 복구도).
- [ ] **판 사진을 베끼는 프레임에 안 그리면 베낄 그림이 없다** — WebGL 캔버스는 화면에 낸 뒤 버퍼를 비운다 — 셸은 game:redraw 이벤트로 다음 프레임 강제 그리기.
- [ ] **시간으로 움직이는 셰이더 uniform 을 안 적으면 물결이 멈춘다** — uniform 값도 적는다. 계속 움직이는 무대는 FrameGate 를 붙이지 않는다.

## 완성 기준 체크리스트
- [ ] 말이 움직일 때만 아래 줄(FrameGate)에 그린 프레임 칸이 켜지고, 가만히 있을 땐 꺼진다
- [ ] 위 줄(늘 그리기)은 내내 켜져 있다 — 「최근 60프레임 중 그린 수」 비교
- [ ] 창 크기를 바꾸거나 다른 탭에서 돌아와도 화면이 비지 않는다

## 이 기술 정보
- id: `u81` · 분류: 게임 시스템 · AI › 플랫폼 · 성능 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 값 적기 · 비교 비용(물체 수에 비례)이 그리기보다 훨씬 싸다. 멈춘 동안 GPU 일은 0.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u81
- 쓰면 좋을 때: 차례제 3D 게임 — 화면 대부분이 멈춰 있다 / 폰에서 오래 켜 두는 화면
- 쓰지 말 때: 입자 · 흐르는 물 같이 계속 움직이는 것이 있는 무대 — 매번 바뀌어 이득이 없다 / 값 적기를 빠뜨린 효과(시간 uniform 등)가 있는 장면 — 화면이 멈춘 채로 남는다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u81 견본 항목 — `src/demos/demosSystem.ts:1168`
```ts
  u81: {
    kind: '2d',
    caption: '위: 늘 그리기 — 칸마다 그림 · 아래: FrameGate — 말이 움직일 때만 그려서 배터리가 덜 닳아요',
    make() {
      const moving = (x: number): boolean => x % 1.8 < 0.55;
      return {
        draw(g, w, h, t) {
          stage(g, w, h, NAVY);
          // 미니 판
          box(g, 12, 12, 96, 96, 8, '#e6bf82');
          for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) box(g, 16 + i * 22, 16 + j * 22, 20, 20, 3, (i + j) % 2 ? '#f4d9a8' : '#d7a868');
          const hop = Math.floor(t / 1.8);
          const k = clamp((t % 1.8) / 0.55);
          const a = hop % 4;
          const b = (hop + 1) % 4;
          const px = lerp(a, b, ease(k));
          circle(g, 27 + px * 22, 82 - Math.sin(ease(k) * Math.PI) * 10, 8, '#ff7a6b', '#a0402a', 1.5);
          txt(g, moving(t) ? '움직이는 중' : '가만히', 60, 118, 9, moving(t) ? '#ffd23f' : '#9ab0ff', 'center', 800);
          // 줄
          const N = 22;
          const fps = 12;
          const f0 = Math.floor(t * fps);
          const lanes: [string, number, boolean][] = [
            ['늘 그리기', 30, false],
            ['FrameGate', 106, true],
          ];
          let drawnGate = 0;
          for (let i = 0; i < 60; i++) if (moving((f0 - i) / fps)) drawnGate++;
          for (const [name, y, gate] of lanes) {
            txt(g, name, 120, y - 6, 9.5, gate ? '#7af0c8' : '#ffb2a8', 'left', 800);
            for (let i = 0; i < N; i++) {
              const fi = f0 - (N - 1 - i);
              const draw = !gate || moving(fi / fps);
              const x = 120 + i * 8.5;
              box(g, x, y + 2, 7, 26, 2, draw ? (gate ? '#3ccf7a' : '#ff7a6b') : 'rgba(255,255,255,0.12)');
              if (i === N - 1) box(g, x - 1, y + 1, 9, 28, 2, null, '#fff', 1.2);
            }
            const lvl = gate ? 1 - ((t % 24) / 24) * 0.3 : 1 - ((t % 24) / 24) * 0.92;
            battery(g, 120, y + 40, 46, 16, lvl);
            txt(g, gate ? `최근 60칸 중 ${drawnGate}번 그림` : '최근 60칸 중 60번 그림', 172, y + 48, 8.5, '#cdd6ff', 'left', 700);
          }
          txt(g, '값이 바뀔 때만 그린다', 60, 150, 9.5, '#fff', 'center', 800);
          txt(g, '말 위치 · 카메라 ·', 60, 166, 8, '#9ab0ff', 'center', 700);
          txt(g, '애니메이션을 지켜봐요', 60, 178, 8, '#9ab0ff', 'center', 700);
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [고정 물체 합치기 (그리기 호출 줄이기)](https://ai-techstudio.web.app/ai/t/i391.md) `i391`
- 다음에 해 볼 기술: [LOD · 텍스처 예산 (폰에서도)](https://ai-techstudio.web.app/ai/t/i478.md) `i478` · [접근성 · 움직임 줄이기](https://ai-techstudio.web.app/ai/t/u86.md) `u86`
