Cloudwiki

Web sandbox 프로젝트/구상/엔진 최적화 방안

주의: 이 문서는 구상 문서다
여기 적힌 A·B·F 항목은 **아직 하나도 구현되지 않았다**. 조사로 찾아낸 후보와 그 근거를 모아 둔 것이고, 착수 순서와 채택 여부는 아직 정해지지 않았다. **이미 구현된 것**은 아래 "이미 한 것" 절에만 있다.

또한 이 문서의 런타임 수치는 **브라우저에서 새로 재지 않았다**. 전부 저장소 `docs/PERFORMANCE.md`에 기록된 기존 실측치(CI 샌드박스 컨테이너 1회 측정)를 인용한 것이고, 정적으로 직접 센 값(히든 클래스 85개, 물질 138종, `DIR8` 순회 109군데 등)만 이 조사에서 새로 계산했다. 절대 ms는 기기마다 다르므로 **패스 간 비중**만 가져갈 것.


## 이 문서의 경계

최적화는 이 프로젝트의 우선순위 축에서 ③ 과학적 고증이 아니라 **① 재미와 ② 편의성에 직결**된다. 연산 예산이 남아야 더 큰 그리드·더 많은 물질·더 화려한 폭발을 감당할 수 있고(재미), 3초 주기 프레임 히치가 사라져야 브러시가 손에 붙는다(편의성). 반대로 **최적화는 고증을 바꾸지 않는다** — 이 문서가 다루는 것의 대부분은 "같은 결과를 더 싸게 내는 것"이고, 결과 자체를 바꾸는 항목은 별도로 표시한다.

`docs/PERFORMANCE.md`와 `docs/WASM-ENGINE-PORTING.md`는 **실측 기록과 로드맵**이다. 이 문서는 그 뒤에 남은 **후보 제안**이다. 두 문서가 서로를 인용하며 순환하지 않도록 경계를 이렇게 둔다.

- 기존 문서 = "무엇을 쟀고, 무엇을 했나"
- 이 문서 = "그래서 다음에 무엇을 할 수 있나, 그리고 각각을 어떻게 증명하나"

---

## 이미 한 것 (여기까지는 완료)


계측 인프라
**패스별 프로파일러 `?perf`** — `src/game/engine/profiler.ts`. 틱을 heat / ca / objects / drift / render 5버킷으로 쪼개 누적. 플래그가 없으면 계측 호출 자체를 건너뛰어 프로덕션 비용 0.

**고정 벤치 장면 `?bench=empty|sparse|static|active`** — `src/game/engine/benchScenarios.ts`. 고정 PRNG라 재현 가능하고, 벤치 로드는 휘발성이라 오토세이브를 차단한다.

**A/B 스위치 `?fullscan`** — 활성-타일 스캔을 끄고 같은 기기에서 on-vs-off를 잰다.

실제 최적화
**활성-타일 CA 스캔** — `src/game/engine/dirtyTiles.ts`. 16×16 타일 중 활성 셀이 있는 것만 스캔. **비트 동일**이며 `test/active-tiles.ts`가 6장면·660틱·4중력으로 증명한다. 전형(sparse) ~1.8×, 빈 장면 ~30×, 밀집 장면 중립.

**열확산 Rust/WASM 커널** — `wasm/heat/`, `src/game/engine/heatWasm.ts`. JS 폴백 투명 유지. 기본 보드에서 ~1.35×.



### 실측이 가리키는 지도


| 장면 | 열 | CA | 렌더/프레임 | 틱 합 |
|---|---|---|---|---|
| empty | 0.81 (40%) | 0.97 (48%) | 2.33 | 2.01 |
| static | 3.01 (17%) | **14.61 (82%)** | 2.88 | 17.85 |
| active | 2.03 (18%) | **8.78 (80%)** | 2.35 | 11.03 |

세 줄로 요약하면 이렇다.

1. **점유 장면에서 CA 스캔이 틱의 80%+** — 여기가 유일한 큰 덩어리다.
2. **열은 17~18%이고 이미 WASM에 올라가 있다** — SIMD를 더 얹어도 틱 전체로는 한 자릿수 %.
3. **렌더는 장면과 무관하게 프레임당 ~2.3~2.9 ms 상수** — 시뮬은 30 Hz(`SIM_HZ_AT_1X`)인데 렌더는 주사율이므로, 고주사율 화면에서 이 상수가 곱해진다.

활성-타일 스캔은 **빈 공간**을 지웠다. 그런데 밀집 장면이 중립인 것에서 보이듯, **점유 셀 1개당 비용 자체는 아직 손대지 않았다.** 이 문서가 다루는 것이 정확히 그 지점이다.

```mermaid
flowchart TD
  S["step() 시작"] --> M["moved / overlayMoved 전면 fill(0)"]
  M --> H["heat 버킷: diffuseHeat x3 + radiateHeat"]
  H --> R["전역 필드 리셋 (wind, magnetFields)"]
  R --> C["ca 버킷: scanTiles → updateCell"]
  C --> O["objects 버킷: stepObjects"]
  O --> D["drift 버킷: driftBackground"]
  D --> E["틱 종료"]
  M -.->|계측 밖| X1["F3"]
  H -.->|diffuse와 radiate가 한 버킷| X2["F1"]
  C -.->|반응·라이프·update·이동이 한 덩어리| X3["F2"]
  O -.->|장면에 오브젝트 0개| X4["F5"]
```

---

## ★ 핵심 발견 — `Material` 객체가 메가모픽이다

이번 조사의 중심 결론이다. `docs/WASM-ENGINE-PORTING.md`가 "dispatch 단형화"라고만 적어 둔 막연한 항목이, **정적으로 측정 가능한 구체적 문제**였다.

### 무엇이 문제인가

`src/game/materials/registry.ts`의 `register()`는 스프레드로 물질 객체를 만든다. `Material` 인터페이스는 필드가 **74개**(필수 5 + 옵셔널 69)이고, 물질마다 선언하는 필드 집합이 전부 다르다. 따라서 `{...def}`가 만드는 V8 히든 클래스가 물질마다 갈린다.

`register({ ... })` 리터럴을 brace-match로 전부 뽑아 최상위 키 시퀀스를 센 결과다.


138 (register() 리터럴 개수)
85 (서로 다른 최상위 키 시퀀스)


- 1개짜리 shape가 **66개**, 2개 9, 3개 5, 4개 2, 6개 2, 19개 1
- 자주 쓰이는 필드 빈도: `update` 127, `category` 125, `combustible` 20, `colorVary` 17, `glow` 16, `friction` 15, `freeze` 13, `conductive` 12, **`reactions` 6**

V8의 인라인 캐시는 shape 4개까지 polymorphic이고, 그 이상은 **megamorphic**(스텁 캐시 해시 조회)으로 떨어진다. 85개는 한참 넘는다.

### 그런데 핫 루프가 아직 그걸 객체로 읽는다

시뮬레이션 핫 패스에서 `getMaterial(id).<prop>` 형태로 남아 있는 곳을 파일 단위로 세면 이렇다.


| 파일 | 무엇을 읽나 | 얼마나 자주 |
|---|---|---|
| `src/game/engine/Simulation.ts` | `.update` | **점유 셀마다 매 틱** |
| `src/game/engine/reactions.ts` | `.reactions` | **점유 셀마다 매 틱** (선언 물질은 138종 중 6종) |
| `src/game/engine/SimContext.ts` | `.density` `.phase` `.porous` `.porousPowder` `.freeze` | **이동 시도마다** (파일 내 `getMaterial` 16회) |
| `src/game/engine/behaviors.ts` | `.friction` `.viscosity` `.surfaceTension` `.density` | 낙하·확산마다 (파일 내 15회) |

### 대조군이 같은 저장소 안에 있다

`Simulation`은 **이미 LUT 평탄화 패턴을 알고 쓰고 있다** — 생성자에서 `cond` / `lifeP` / `lifeInto` / `magneticId` / `radiationP`를 256엔트리 `Float32Array`·`Uint8Array`로 굽고, `updateCell`은 `this.magneticId[id]`처럼 배열 인덱싱으로만 읽는다. 정작 가장 뜨거운 두 개(`reactions`, `update`)만 객체 프로퍼티로 남아 있다.

더 결정적인 대조군은 렌더러다. `src/game/render/CanvasRenderer.ts`는 생성자에서 **256엔트리 LUT를 30개 넘게** 굽는다(`palette` `vary` `varyMode` `renderAsAux` `freezeTemp` `frost` `lattice` `checker2x2` `brickPattern` `wooferRim` `rotorSpinShift` `tintBlockMask` …). 렌더 루프는 `Material` 객체를 **한 번도 만지지 않는다.**

성공: 결론
"물질 객체 138개가 85개 히든 클래스로 갈려 있다"는 문제와 "256엔트리 LUT로 평탄화한다"는 해법이 **같은 저장소 안에 이미 나란히 있다.** 렌더러가 쓰는 규율을 `Simulation` / `SimContext` / `behaviors`로 옮기는 것(A1~A3)이 지금 가장 이득/노력 비가 좋은 작업이며, 전부 비트동일이고 기존 하네스로 검증 가능하다.


---

## 간단히 적용 가능한 방안 (A1~A9)

전부 순수 JS이고 PR 하나 단위로 끊을 수 있다. 각 항목은 **근거 / 방법 / 예상 이득 / 리스크 / 비트동일성** 다섯 줄로 쓴다.

### [A1] `reactions` 유무를 평탄 LUT로 게이팅 [최우선]

- **근거** — (a) *비어 있는 것*: `Simulation.updateCell`이 점유 셀마다 `tryReact`를 부르고, `src/game/engine/reactions.ts`가 매번 `getMaterial(...).reactions` 메가모픽 로드를 한다. **그런데 `reactions`를 선언한 물질은 138종 중 6종뿐**이다 — 즉 셀의 압도적 다수가 "없는 것을 확인하려고" 메가모픽 로드를 낸다. (b) *재사용 인프라*: `Simulation` 생성자의 기존 LUT 구축 블록(`magneticId` 등)에 배열 하나를 더 넣는다. (c) *비용*: 코드 4~5줄.
- **방법** — `hasReactions: Uint8Array(256)`을 생성자에서 채우고, 호출부를 `this.hasReactions[id] !== 0 && tryReact(...)`로 게이팅한다.
- **예상 이득** — 점유 셀 100%에서 메가모픽 로드 1회 제거. CA 패스의 수 % 수준.
- **리스크** — 낮음. 레지스트리는 모듈 로드 후 불변이다(등록은 `src/game/materials/index.ts` import의 부수효과뿐).
- **비트동일** — [완전 동일]. 게이트가 false인 경우 `tryReact`는 어차피 첫 줄에서 `false`를 반환하고 RNG를 뽑지 않는다.

### [A2] `update` 함수 포인터를 평탄 배열로 [최우선]

- **근거** — (a) `Simulation.updateCell`의 `getMaterial(id).update?.(x, y, ctx)`가 점유 셀마다 도는 유일한 필수 경로다. (b) A1과 같은 생성자 블록을 재사용한다. (c) 코드 5줄 + `defaultUpdate` 배선을 그대로 옮기는 것뿐.
- **방법** — 생성자에서 `updateFn = new Array(256)`을 채우고, 호출은 `const u = this.updateFn[id]; if (u !== undefined) u(x, y, this.ctx);`
- **예상 이득** — A1과 같은 규모. 콜사이트 자체는 여전히 메가모픽(서로 다른 함수 127개)이지만, **프로퍼티 로드가 배열 인덱싱으로 바뀐다.**
- **리스크** — 낮음. `src/game/engine/behaviors.ts`의 `defaultUpdate`가 Solid/Empty에 `undefined`를 반환하는 것까지 그대로 옮기면 된다.
- **비트동일** — [완전 동일]

### [A3] `SimContext`가 읽는 물질 속성의 LUT 평탄화 [최대 이득]

- **근거** — (a) `src/game/engine/SimContext.ts`의 `tryMove` 하나가 `getMaterial`을 3번 부르고, `isFrozen`이 또 부르고, `moveDown`이 또 부른다. `src/game/engine/behaviors.ts`의 `fallAndPile` / `updateLiquid` / `surfaceTensionMove`도 마찬가지. **이동 1회당 메가모픽 로드 5~8회.** `tryMove`는 CA 스캔의 최다 호출 함수다. (b) `CanvasRenderer`가 이미 하는 것과 문법적으로 똑같은 작업이다 — 참고 구현이 저장소 안에 있다. (c) 대상 속성이 10개쯤 되어 A1·A2보다 diff가 크다.
- **방법** — `density` / `phase` / `porous` / `porousPowder` / `freeze.temp` / `friction` / `viscosity` / `surfaceTension` / `liquidOverlap` / `insulated`를 256엔트리 배열로 굽는다.
- **예상 이득** — **이 목록에서 가장 큰 순수-JS 후보.** 움직이는 셀이 많은 장면에서 CA 패스의 5~15% 추정.
- **리스크** — 중간. LUT 구축을 빠뜨린 속성이 생기면 **조용히 틀린 값**이 나온다(이 프로젝트가 반복해서 데인 실패 유형). 새 물질을 추가할 때 LUT도 같이 갱신돼야 하므로 `.claude/skills/add-material-or-object` 체크리스트에 항목을 추가해야 하고, 더 안전한 쪽은 `test/acidmetal.ts`가 쓰는 방식 — **레지스트리를 훑어 LUT와 대조하는 검사**를 같이 만드는 것이다.
- **비트동일** — [조건부]. `density` `friction` `viscosity` `surfaceTension` `freeze.temp`는 **반드시 `Float64Array`**로 저장할 것. `Float32Array`에 넣으면 반올림 때문에 `src.density > tgt.density` 비교 결과가 뒤집힐 수 있고, 그 순간 비트 비동일이 된다. Float64면 완전 동일.

### [A4] `DIR8` / `DIR4`를 평탄 `Int8Array` 쌍으로

- **근거** — (a) `src/game/engine/directions.ts`는 배열의 배열이고, 전 코드베이스가 `for (const [dx, dy] of DIR8)` 형태로 **109군데**(+ `DIR4` 21군데) 순회한다. 이터레이터 + 내부 배열 구조분해 + 포인터 추적이 매번 든다. 최악은 `behaviors.ts`의 `surfaceTensionMove`로, `DIR8` 안에 `DIR8`이 중첩되어 **가장자리 셀 1개당 최대 72회 이웃 조회**가 난다. (b) 새 인프라가 필요 없다 — 상수 2개를 추가할 뿐. (c) 전면 교체는 diff가 130군데로 커져 리뷰 비용이 이득보다 클 수 있다.
- **방법** — `DIR8X` / `DIR8Y`를 `Int8Array`로 추가하고 `for (let k = 0; k < 8; k++)`로 바꾼다. 기존 `DIR8`은 남겨 점진 이행.
- **예상 이득** — 불확실. V8이 escape analysis로 상당수를 이미 제거한다. **다중 중첩 지점(표면장력·방사선·연소) 3~5곳만 먼저 바꿔 `?perf`로 재보고 전면 확대를 결정하는 것**이 옳은 순서다.
- **리스크** — 낮음(순회 순서 유지).
- **비트동일** — [완전 동일] (같은 순서 = 같은 RNG 소비 순서).

### [A5] `randomPerp`의 배열 할당 제거

- **근거** — (a) `SimContext.randomPerp()`가 호출마다 `[x, y]` 튜플을 새로 할당하고, 호출처가 **6곳**(`pushAside` `moveDiagonalDown` `moveDiagonalUp` `moveSideways` `moveSidewaysMix` 외 1)이다. 움직이는 셀 하나가 한 틱에 2~4번 부를 수 있다. (b) 인프라 불필요. (c) 코드 6줄.
- **방법** — `private perpSx` / `perpSy` 필드에 쓰고 반환값 없이 호출한다.
- **예상 이득** — 초당 10⁵~10⁶ 규모의 소형 배열 할당 제거. **평균 ms보다 프레임 지터(마이너 GC 빈도)에서 나타날 가능성이 높다.**
- **리스크** — 낮음.
- **비트동일** — [완전 동일] (`Math.random()` 호출 순서 불변).

### [A6] 렌더 루프 EMPTY 조기탈출

- **근거** — (a) `CanvasRenderer.render`가 프레임마다 전면 그리드를 1회 순회하는데, **EMPTY(id 0) 셀도 16단 else-if 체인 전체와 LUT 로드 20여 회를 통과한다.** sparse 장면은 셀의 ~88%가 EMPTY다(벤치 정의상 ~12% 점유). 그런데 `src/game/materials/empty.ts`는 `colorVary` / `glow` / `freeze` / 패턴 플래그를 하나도 선언하지 않으므로 **EMPTY의 LUT 값은 전부 0이고 최종 결과는 항상 `pal[EMPTY]`** — 즉 20회 로드가 전부 낭비다. (b) 기존 LUT를 그대로 쓴다. (c) 조건절 한 줄.
- **방법** — `id`를 읽은 직후 `id === EMPTY && wind[i] === 0 && overlay[i] === 0 && !windHalo`이면 `buf[i] = palEmpty; continue;`
- **예상 이득** — sparse 장면 렌더의 **30~50% 추정**(2.3 ms → 1.2~1.6 ms). 밀집 장면은 거의 0.
- **리스크** — 낮음~중간. 히트맵 오버레이 경로는 이미 위에서 분기해 나가므로 충돌이 없다. 바람/헤일로 조건을 잘못 쓰면 팬 스트릭이 사라진다 → **팬을 켠 상태로 사람이 눈으로 확인해야 한다**(브라우저 테스트는 유저 요청 사항).
- **비트동일** — [시뮬 무관]. 다만 **픽셀은 동일해야** 하고, 그걸 고정할 픽셀 diff 검사가 지금은 없다(아래 "검증 전략" 참고).

### [A7] `salt` / `sugar` 플러드필의 문자열 키 · `shift()` 제거

- **근거** — (a) `src/game/materials/salt.ts`의 `salinatePocket`은 `Set<string>`에 `` `$,$` `` 키를 넣고, 큐를 `Array<[number, number]>`로 두고 `shift()`로 뽑는다 — **이웃마다 문자열 생성 + O(n) shift + 튜플 할당**. `src/game/materials/sugar.ts`도 같은 패턴이다. (b) *같은 저장소에 이미 올바른 구현이 있다*: `src/game/materials/napalm.ts`와 `src/game/materials/co2.ts`는 숫자 키 + head 인덱스를 쓴다. 옮겨 적기만 하면 된다. (c) 코드 10줄.
- **방법** — `Set<number>`에 `y * width + x` 키, 큐는 `number[]`에 x·y를 번갈아 push하고 head 인덱스로 소비.
- **예상 이득** — 소금·설탕을 물에 뿌리는 장면 한정. 절대 이득은 작지만 **문자열 할당은 GC 스파이크로 나타나는 유형**이라 지터 관점에서 값어치가 있다.
- **리스크** — 낮음.
- **비트동일** — [완전 동일] (`DIR4` 방문 순서와 `spawn` 호출 순서 유지 시 불변).

### [A8] 중복 `getMaterial` / `idx` 호출 제거

- **근거** — (a) `SimContext.tryMove`가 같은 표현식 안에서 `getMaterial(targetId)`를 3번 부른다. `isFrozen`은 호출부가 이미 잡아 둔 `src`를 두고 물질을 다시 조회한다. `SimContext.set`은 `gi = grid.idx(x, y)`를 잡아 놓고도 뒤에서 `tint[grid.idx(x, y)]`로 재계산하고, `SimContext.spawn`은 `gi`를 잡고도 `idx`를 두 번 더 재계산한다. (b) 인프라 불필요 — 지역 변수 재사용뿐. (c) 거의 0.
- **방법** — 잡아 둔 지역 변수를 그대로 쓴다.
- **예상 이득** — 작음. A3와 함께 하면 상당 부분이 자동 해결된다.
- **리스크** — 매우 낮음.
- **비트동일** — [완전 동일]

### [A9] `radiateHeat`에 활성-타일 게이팅

- **근거** — (a) `Simulation.diffuseHeat`는 WASM으로 갔고 활성-타일 논증도 받는데, **`radiateHeat`은 둘 다 못 받았다** — 매 틱 전 셀에 대해 `cond[cells[i]]` + `temp[i]` 2 로드 후 조기 탈출한다. (b) 논증 자체는 CA 스캔과 똑같다: 복사원이 되려면 `cond`가 0이 아니어야 하므로 **전부 EMPTY인 타일은 증명상 무해**하다. `dirtyTiles.ts`의 개념을 그대로 재사용한다. (c) 열 패스 순서를 건드리므로 중간 난이도.
- **방법** — `beginTick()`을 열 패스 앞으로 당기거나(순서 의미가 바뀌므로 위험), **점유 타일 비트셋을 따로 유지**하는 쪽이 안전하다.
- **예상 이득** — empty / sparse 장면의 'heat' 버킷 일부. **그런데 지금은 판단할 수 없다** — 'heat' 버킷이 diffuse와 radiate를 합쳐 재기 때문이다(F1). **F1이 A9의 선행 작업이다.**
- **리스크** — 중간(패스 순서 결합).
- **비트동일** — [완전 동일] (스킵된 타일은 아무 일도 하지 않는다).

---

## 대규모 · 포팅급 방안 (B1~B7)

여기부터는 PR 하나로 안 끝나고, 대부분 별도 승인이 필요하다.

### [B1] 렌더 프레임 스킵 — 변경이 없으면 합성 자체를 건너뛰기

- **근거** — (a) `src/game/Game.ts`의 프레임 루프가 매 rAF마다 **무조건** `renderer.render(grid)`를 부른다. 시뮬은 기본 30 Hz(`SIM_HZ_AT_1X`)인데 렌더는 디스플레이 주사율이므로, 120 Hz 화면에서는 **프레임 4개 중 3개가 직전과 픽셀이 동일한 이미지를 다시 합성**한다(바람·자석 링·충격파 애니메이션이 없을 때). 렌더는 장면과 무관하게 프레임당 2.3~2.9 ms 상수다. (b) 변경 소스가 전부 `Game.ts` 안에 있다 — 새 레이어가 필요 없다. (c) **변경 소스를 하나라도 빠뜨리면 "화면이 안 갱신됨"이라는 최악의 버그**가 된다.
- **방법** — 게이팅 조건을 명시적으로 열거한다: ① 이번 프레임에 `sim.step()`이 최소 1회 돌았나 ② 브러시가 칠했나(`painter.update()`) ③ 시간 기반 애니메이션이 살아 있나(바람 활성, `grid.magnetFields` 비어 있지 않음, 충격파 큐, 로터 스핀) ④ 오버레이 토글·격자 분할·리사이즈·스냅샷 로드.
- **예상 이득** — 120 Hz 화면에서 렌더 비용 **최대 −75%**, 60 Hz에서 −50%. 일시정지 상태에서는 거의 0.
- **리스크** — [높음]. 안전판으로 "N 프레임에 한 번은 무조건 그린다" 상한을 반드시 둘 것.
- **비트동일** — 시뮬 무관. 시각적으로는 동일해야 함.

### [B2] 렌더 더티 사각 / 타일 단위 재합성

위험: 함정 — 이걸 모르고 착수하면 반드시 버그가 난다
`DirtyTiles.mark`는 **점유(occupancy) 변경만** 추적한다. `Grid.setTemp`와 `Grid.setTint`는 **마크를 하지 않는다**(코드로 확인 — 두 메서드는 배열에 값만 쓴다). 그런데 렌더러는 `temp`를 읽어 glow 색을 정하고 `freezeTemp`로 서리 판정을 한다.

**따라서 현재의 dirty 집합을 그대로 렌더 더티 사각으로 재사용하면 용암이 식는 색 변화와 서리가 끼는 변화가 화면에서 누락된다.** 렌더 전용 dirty 마킹을 별도로 만들거나(온도·틴트 쓰기도 마크), 열 패스가 바꾼 셀을 타일 단위로 따로 표시해야 한다.


- **근거** — (a) `CanvasRenderer.render`가 전면 순회 + 전면 `putImageData`이고 dirty-rect 인자를 안 쓴다. (b) `dirtyTiles.ts`가 이미 "이번 틱에 바뀐 타일" 집합을 갖고 있다 — 다만 위 함정 때문에 **그대로는 못 쓴다.** (c) 함정 + `putImageData` dirty-rect 규격 두 가지를 동시에 다뤄야 한다.
- **예상 이득** — sparse / static에서 렌더 −50% 이상. **B1과 곱해진다.**
- **리스크** — [높음]
- **비트동일** — 시뮬 무관.

### [B3] Web Worker 오프메인스레드

- **근거** — (a) 코드에 `new Worker` 0건, `SharedArrayBuffer` 0건, `OffscreenCanvas` 0건이다. `docs/WASM-ENGINE-PORTING.md`가 Phase 1로 잡아 둔 항목. (b) **결합 표면을 실측했다** — 엔진 밖에서 그리드를 **직접 쓰는** 지점은 `src/game/ui/PointerPainter.ts` **11곳**과 `src/game/Game.ts` **3곳**뿐이다. `src/state/persistence.ts`·`src/state/snapshots.ts`·`CanvasRenderer`는 읽기 전용이다. **문서가 주장한 "지금이 싸다"는 코드로 확인된다** — 물질이 200종이 되고 UI가 더 붙으면 이 14곳이 늘어난다.
- **선행 확인 사항** — `public/`에 `_headers`가 없고 `wrangler.toml`에 `main`이 없다(assets-only 배포). 즉 **COOP/COEP 헤더 주입 수단이 현재 0이므로 `SharedArrayBuffer` 가용성을 착수 전에 확인해야 한다.** 안 되면 transfer 더블버퍼로 간다.
- **예상 이득** — **틱 속도는 안 오른다.** 대신 입력 반응성과 렌더 프레임이 보장되고, 이후 모든 WASM 확장의 전제가 된다.
- **리스크** — [높음] (브러시 지연, 결정론, 저장 포맷은 불변 유지 가능).
- **비트동일** — 단일 워커 직렬 스캔을 유지하면 동일. 단 `Math.random()`이 워커에서 다른 시드로 시작하므로 **재현성이 필요하면 명시 시드 xorshift로 교체**해야 한다. 그 seam은 `SimContext.chance` / `randInt` 두 개뿐이라 물질 코드 138종은 한 줄도 안 바뀐다 — 그리고 그 김에 **재현 가능한 버그 리포트와 리플레이**라는 재미 축 부수 이득이 따라온다.[* 이 문서에서 ① 재미 축에 직접 기여하는 유일한 최적화 항목이다. 나머지는 전부 ② 편의성 기여다.]

### [B4] WASM 열 커널 제로카피 + 타일 비트맵

- **근거** — (a) `src/game/engine/heatWasm.ts`가 틱마다 `cells`(n B) + `cond`(1 KB, **상수인데 매 틱**) + `temp`(4n B)를 밀어 넣고 `temp`(4n B)를 되받는다 — **틱당 `9n + 1024` 바이트 복사 + 뷰 객체 4개 할당.** 기본 보드(73,080셀)에서 658 KB/틱, `MAX_CELLS`(600,000)에서 **5.4 MB/틱**. (b) 커널은 이미 있다. 배관만 고친다. (c) 선형 메모리 grow 시 `ArrayBuffer` detach 함정이 있고, 코드 주석이 이미 그걸 알고 있다.
- **방법 (3단계로 쪼갤 수 있다)**
  - **1단계 (쉬움)** — `cond`를 최초 1회만 복사(더티 플래그), 뷰 객체 4개를 모듈 스코프에 캐시하고 `memory.buffer` 재할당 시에만 재생성.
  - **2단계 (큰 것)** — `Grid`의 `cells` / `temp` / `tempScratch`를 **WASM 선형 메모리 위의 뷰로 직접 할당** → 복사 0. 리사이즈 때만 재할당.
  - **3단계** — 커널에 점유 타일 비트맵을 넘겨, 전부 공기인 타일은 행 단위 복사로 처리. A9와 동일한 비트동일 논증.
- **예상 이득** — 기본 보드에선 작다(수십 µs). **큰 그리드에서 의미가 커진다.** 3단계는 empty / sparse의 열 패스를 크게 줄인다.
- **리스크** — 중간.
- **비트동일** — [완전 동일] (3단계 포함).

### [B5] 열 커널 SIMD 승격 [후순위 유지]

- **근거** — (a) `wasm/heat/Cargo.toml`에 SIMD 플래그가 없고 빌드 스크립트에도 `+simd128`이 없다. 즉 아직 안 켰다. (b) Rust 커널이 이미 있으므로 언어 장벽은 없다. (c) **열 슬라이스가 17~18%뿐이라 그 안에서 2배를 내도 틱 전체로는 한 자릿수 %**이고, f32 SIMD는 **비트동일이 깨져** 골든 테스트를 오차 허용으로 완화해야 한다.
- **판정** — `docs/PERFORMANCE.md`가 이미 후순위로 결론 낸 항목이고, 이번 조사에서 뒤집을 근거를 찾지 못했다. **CA 스캔 계열(A1~A3, B6)이 전부 끝난 뒤에 재검토.**
- **비트동일** — [비동일]

### [B6] Tier 2 — 점유하지만 정적인 타일 재우기

- **근거** — (a) 현재 활성-타일 스캔은 점유 타일을 **항상** 스캔한다(그래서 밀집 장면이 중립이다). 정착해 아무 변화도 없는 점유 타일까지 재우는 것이 **밀집 장면에서 남은 최대 이득**이다. (b) `dirtyTiles.ts` 구조를 그대로 확장한다. (c) **비트동일이 아니다** — RNG로 자발 행동하는 물질(수명 감쇠, 불 깜빡임, 확률 반응)을 재우면 안 되고, 방해 전파에 1틱 지연이 생긴다.
- **★ 이 문서가 더할 관찰** — 물질별 "restless" 분류를 **손으로 유지하면 조용히 틀린다.** 이 프로젝트가 **리뷰에서 세 번 어긋난 것이 정확히 손으로 옮긴 명단**이었고, 그래서 `test/acidmetal.ts`(`acidHydrogen` 태그 명단)와 `test/radiation.ts`(방사능·생명 커버리지)가 **레지스트리를 훑어 명단을 출력하는 검사**를 갖고 있다. 그런데 restless는 그보다 더 어렵다 — `chance()` / `randInt()` 호출 여부를 **정적으로 판단할 수 없기 때문**이다. 따라서:
  - **차선**: `Material.restless?: boolean` 태그 + 레지스트리 대조 검사 (여전히 손 명단이라 조용히 틀릴 수 있음)
  - **권장**: **런타임 자동 분류** — 스캔 중 그 셀의 `update`가 RNG를 뽑았는지 `SimContext`가 카운터로 관찰하고, 한 번이라도 뽑은 물질은 영구 restless로 승격. 손 명단이 필요 없고 새 물질에 자동으로 따라온다.
- **예상 이득** — 밀집 장면(현재 중립인 구간)에서 최대 이득.
- **리스크** — [높음]
- **비트동일** — [비동일]. **별도 PR + 승인 대상.**

### [B7] 물질 데이터 VM / 물질 전체 Rust 재작성 [안 함]

- **근거** — (a) `docs/WASM-ENGINE-PORTING.md`가 Phase 3으로 적어 두고 **"아마 영영 안 함"**으로 기울어 있는 항목이다. (b) 재사용할 인프라가 없다 — 138종 물질의 `update`를 데이터로 표현하려면 선언형 반응 테이블이 훨씬 넓어야 하는데, **현재 `ReactionRule`을 쓰는 물질은 138종 중 6종뿐**이고 나머지는 전부 하드코딩 `update`다. 다단계 거동(폭발 survey, 분별증류, 연소 전선)은 애초에 테이블로 못 옮긴다. (c) 비용이 프로젝트 전체 재작성 급.
- **판정** — **안 함 결론 유지.** 오히려 이 문서의 핵심 발견이 그 결론을 강화한다 — **VM 없이도 LUT 평탄화(A1~A3)만으로 dispatch 비용의 상당 부분을 회수할 수 있다.** VM은 그 회수분을 확인한 뒤에도 부족할 때 다시 꺼낼 카드다.

---

## 계측 사각지대 (F1~F10)

**"측정하지 않은 것을 최적화하지 마라"가 이 프로젝트의 Phase 0 규율**인데, `?perf` / `?bench=`가 지금 못 재는 영역이 열 군데 있다. 아래 F1·F2는 A1~A3·A9의 효과를 각각 확인하기 위한 **선행 작업**이다.


| # | 사각지대 | 왜 문제인가 | 제안 |
|---|---|---|---|
| **F1** | 'heat' 버킷이 **diffuse와 radiate를 합쳐** 잰다 | 프로파일러 마크가 `radiateHeat()` 뒤에 하나뿐이다. **A9의 가치를 판단할 수 없다** | 패스명을 `heat` / `radiate`로 분리 |
| **F2** | 'ca' 버킷이 **반응·라이프·update·이동을 한 덩어리**로 잰다 | A1 / A2 / A3의 효과를 각각 확인할 수 없다 — 셋을 한 PR에 몰아넣게 만드는 압력이 된다 | 최소 `react` / `update` / `move` 3분할 |
| **F3** | `moved.fill(0)` / `overlayMoved.fill(0)`이 **어느 버킷에도 없다** | 프로파일러 마크가 이 두 줄 **뒤**에 있다. 기본 보드에서 2×73 KB, `MAX_CELLS`에서 **2×600 KB memset/틱**이 통째로 안 보인다 | 마크를 `step()` 첫 줄로 올리거나 `bookkeeping` 버킷 추가 |
| **F4** | **오토세이브가 완전히 미계측** | `Game.ts`가 `WORLD_AUTOSAVE_MS = 3000`마다 `saveWorld` → `serializeWorld`를 부른다. 그 안은 RLE 인코더 여러 회 + 전면 크기 중간 배열(`encodeCellsRle`, `quantizeTemps`의 `Int16Array(n)`, `encodeTempRle`) + `String.fromCharCode(...subarray)` 스프레드 + `btoa` + `JSON.stringify` + 대형 문자열 비교다. **3초 주기 프레임 히치의 유력 후보인데 아무도 안 재고 있다** | `save` 패스 추가. 그 뒤 워커 / `requestIdleCallback`로 내리는 것은 별도 후보 |
| **F5** | 'objects' **0.00 ms는 "빠르다"가 아니다** | `stepObjects`는 `objects.length === 0`이면 즉시 반환한다. 그리고 `benchScenarios.ts`에 오브젝트 시딩이 없다. **즉 오브젝트 패스는 사실상 미측정 상태다** | `?bench=objects` 추가(드럼통·나무상자 N개 낙하) |
| **F6** | 렌더가 **프레임당 ms로만** 나와 틱당 예산과 직접 비교가 안 된다 | 프로파일러가 render만 프레임 수로 나누고, HUD의 `sim` 합계에도 render가 안 들어간다. B1의 이득 크기를 눈으로 못 잰다 | HUD에 "초당 총 ms" 열 추가 (틱합×틱레이트 + render×fps) |
| **F7** | **WASM 열 on/off A/B 플래그가 없다** | CA에는 `?fullscan`이 있는데 열에는 대응물이 없다. `USE_WASM_HEAT`는 `config.ts` 하드코딩이라 URL로 못 끈다 | `?heatjs` 플래그 추가 |
| **F8** | **WASM 벤치의 1.35×가 호스트 복사비를 타이밍 밖에 뒀다** | `wasm/bench/heat-bench.mjs`의 WASM 러너는 `heat_alloc` + `.set()`을 클로저 생성 시 1회만 하고, 측정 대상 클로저는 커널 호출만 한다. **즉 문서의 1.35×는 앱 안 실측치가 아니다**(B4가 지우려는 바로 그 비용이 빠져 있다) | F7과 묶어 앱 안에서 재측정 |
| **F9** | **벤치 장면 커버리지 구멍** | 4종뿐이다. 전기(스파크 체인), 대형 장치 바디, 겹침 다량(젖은 모래층), 방사선, 대형 폭발, **패턴 물질 다량**(벽돌·우퍼·로터·TNT — 렌더 else-if 체인의 비싼 가지), 표면장력 액체(72-probe 경로)가 전부 빠져 있다 | `?bench=electric` / `pattern` / `objects` 추가 |
| **F10** | **GC · 할당 지표가 전혀 없다** | 프로파일러가 wall-clock만 잰다. **A5 / A7의 이득은 평균이 아니라 꼬리에서 나타나는데** 꼬리를 볼 수단이 없다 | 프레임 ms의 p99 / 최대값을 HUD에 추가 |

팁: 입력 패스도 미계측이다
`Game.ts`의 프레임 루프가 매 프레임 `painter.update()`와 `painter.refreshInspect()`를 부르는데 어느 버킷에도 없다. idle일 때는 저렴하겠지만 **브러시를 누른 채 드래그할 때의 비용이 불명**이다. `input` 버킷을 F1~F3과 함께 넣는 것이 싸다.


---

## 검증 전략 — 최적화 PR은 '거동 불변' 증명이 전부다

### 이미 있는 하네스를 그대로 쓸 수 있다 

`test/active-tiles.ts`는 활성-타일 스캔을 증명하려고 만든 것이지만, **구조가 범용이다.**

- 시드 PRNG를 `Math.random`에 덮어씌워 두 경로의 난수 소비를 정렬한다.
- 매 틱 `cells` / `temp` / `aux` / `overlay` / `overlayAux` / `tint` **6개 필드 전부**를 인덱스 단위로 비교하고, 첫 불일치 지점을 필드명과 함께 보고한다(`firstDiff`).
- 온도는 **오차 허용 없이 비트 단위로** 비교한다.
- 6장면·660틱·4중력 방향으로 돌린다.

성공: 실용적 결론
**A1~A5 · A7~A9의 비트동일성은 새 검사 파일을 만들지 않고 증명할 수 있다.** 이 하네스를 "최적화 전 빌드"와 "최적화 후 빌드" 두 개로 각각 돌려 스냅샷을 비교하면 된다 — `firstDiff`가 이미 그 비교 함수 자체다. 새 최적화를 제안할 때는 **어느 기존 하네스가 그것을 지켜 주는지, 아니면 새 하네스가 필요한지**를 항목마다 명시하는 것을 규칙으로 삼는다.


### 새로 필요한 것

- **렌더 픽셀 diff 검사가 없다.** A6 / B1 / B2는 전부 "픽셀이 동일해야 한다"가 합격 조건인데, 그걸 자동으로 확인할 수단이 지금 없다. `?bench` 장면의 캔버스를 고정 틱 수만큼 돌린 뒤 PNG로 뽑아 골든과 비교하는 검사가 필요하다. `test/materialicons.ts`가 이미 **Node 안에서 SVG를 래스터화해 골든과 비교**하고 있으므로 파이프라인의 절반은 있다.
- **오브젝트가 들어간 벤치 장면**(F5) — 만들기 전까지 오브젝트 패스 최적화는 논할 수 없다.

### 하네스 자체의 리스크 두 가지

주의: 최적화 문서가 전제로 깔면 안 되는 것
① **`esbuild`가 `package.json`에 선언돼 있지 않다.** `test/run-*.mjs` **14개 전부**가 `import { build } from 'esbuild'`를 하는데, devDependencies에는 esbuild가 없고 astro/vite의 **전이 의존**으로만 존재한다. 상위 의존성이 esbuild를 벗기거나 메이저를 올리면 **엔진 검증 전체가 한꺼번에 죽는다.** 최적화 작업은 하네스에 전적으로 의존하므로, 착수 전에 esbuild를 명시적 devDependency로 올려 두는 것이 값싼 보험이다.

② **`test:heat`는 `wasm/` Rust 빌드 산출물을 요구한다.** 그게 없는 환경에서는 `npm test`가 첫 줄에서 죽으므로, 그런 환경에서는 개별 스크립트로 돌려야 한다(`CLAUDE.md`에 이미 기록됨).


### 테스트를 새로 붙이는 비용은 거의 0이다

테스트 프레임워크가 없다. `check(name, ok, detail)` 자작 헬퍼와 종료 코드가 전부이고, 각 `test/run-*.mjs`는 동일 패턴(esbuild로 TS 하네스를 Node ESM으로 굽고 `.wasm?url` import를 문자열 스텁으로 갈아 끼운 뒤 `import()`)이다. **러너 하나를 복붙하면 새 검사가 붙는다.** 최적화 PR마다 골든을 하나씩 늘리는 것이 이 저장소에서는 싸다.

---

## 상수를 바꾸는 것 vs 같은 결과를 더 싸게 내는 것

이 문서의 모든 제안은 두 부류 중 하나이고, **둘을 섞으면 증명이 불가능해진다.** 최적화 PR을 열 때 어느 쪽인지 먼저 선언할 것.


| | 같은 결과를 더 싸게 | 상수를 바꿔서 싸게 |
|---|---|---|
| 예 | A1~A9, B1~B4 | `HEAT_DIFFUSION_SUBSTEPS` 3→2, `SIM_HZ_AT_1X` 낮추기, `RADIANT_HEAT_RANGE` 줄이기, B6 |
| 거동 | 불변 | **바뀐다** |
| 골든 | 통과해야 함 | **깨진다 — 갱신 대상** |
| 판정 기준 | 벤치 ms | **① 재미** (플레이 느낌이 나빠졌나) |
| 증명 | 비트동일 가능 | 불가능 — 사람이 판정 |

정보: 튜너블 표면
`src/game/config.ts`에 `export const`가 **69개** 있고 이게 "손잡이"의 전부다. 그중 UI로 노출된 것은 브러시·배속·중력·셀 스케일 등 30여 개이고, **`USE_WASM_HEAT` · `USE_ACTIVE_TILES` 같은 엔진 스위치는 URL 쿼리(`?perf` `?bench=` `?fullscan`)로만 만질 수 있어 일반 사용자에게는 존재하지 않는다.** 최적화 문서가 "플래그로 껐다 켰다 하면 된다"고 쓸 때 그 플래그가 어느 쪽인지 구분해야 한다 — 지금 열 커널에는 `?fullscan`에 해당하는 것이 아예 없다(F7).


---

## 메모리 예산

새 per-cell 배열을 추가하자는 제안(속도장, 압력장, 농도장, 렌더 전용 dirty 플레인 등)은 전부 이 예산에서 나온다.


600,000 (MAX_CELLS (config.ts))
18~19 B (셀당 평면 합계)
약 11 MB (최대 그리드 총량)


셀당 내역: `cells` 1 B + `moved` 1 B + `overlay` 1 B + `overlayAux` 2 B + `overlayMoved` 1 B + `temp` 4 B + `tempScratch` 4 B + `aux` 2 B + `tint` 1 B + `wind` 1 B ≈ **18 B** (+ `bgTint` 등 비영속 필드).

그래서 다음 두 가지가 따라온다.

- **11 MB는 브라우저 기준으로 작다.** 메모리 총량 자체는 병목이 아니다.
- **문제는 대역폭과 세이브 포맷이다.** 열 커널이 이미 메모리 대역폭 한계 부근(~50 Mcell·substep/s에서 처리량 평탄)이므로, 새 평면 하나는 곧 매 틱 추가 스트리밍이다. 그리고 `src/state/persistence.ts`는 `cells` / `temp` / `aux` / `overlay` / `overlayAux` 다섯 평면만 RLE+base64로 저장하므로, **새 평면 = 세이브 포맷 변경 + 구버전 호환 처리**가 딸려온다.
- 참고로 **오브젝트 레이어는 세이브에도 스냅샷에도 직렬화되지 않는다**(스냅샷 로드 시 오히려 목록을 비운다). 오브젝트 관련 최적화는 저장 포맷을 건드리지 않는다는 뜻이기도 하다.

---

## 우선순위


| 순위 | 후보 | 분류 | 예상 이득 | 리스크 | 비트동일 |
|---|---|---|---|---|---|
| 1 | **F1+F2+F3** 계측 세분화 | 간단 | 0 — **나머지 전부의 전제** | 없음 | 해당없음 |
| 2 | **A3** SimContext 물질속성 LUT | 간단 | CA의 5~15% (최대 순수-JS 후보) | 중 (Float64 필수) | 동일 |
| 3 | **A1+A2** reactions/update 평탄화 | 간단 | CA의 수 % | 낮 | 동일 |
| 4 | **A6** 렌더 EMPTY 조기탈출 | 간단 | sparse 렌더 −30~50% | 낮~중 | 시뮬 무관 |
| 5 | **F4** 오토세이브 계측 → 필요시 오프로드 | 간단→중 | 3초 주기 히치 제거 가능 | 낮 / 중 | 시뮬 무관 |
| 6 | **A5+A7+A8** 할당·중복 제거 | 간단 | 평균 소폭, **꼬리 지연 개선** | 낮 | 동일 |
| 7 | **A9** radiateHeat 타일 게이팅 | 간단 | empty/sparse 열 패스 일부 | 중 | 동일 |
| 8 | **F5+F9** 벤치 장면 확충 | 간단 | 0 — 사각지대 해소 | 없음 | 해당없음 |
| 9 | **A4** DIR8 평탄화 (중첩 지점 우선) | 간단 | 불확실 — 3~5곳 먼저 측정 | 낮 | 동일 |
| 10 | **B1** 렌더 프레임 스킵 | 대규모 | 120 Hz에서 렌더 −75% | 높 | 시뮬 무관 |
| 11 | **B4** WASM 열 제로카피 + 타일 비트맵 | 대규모 | 큰 그리드에서 유의미 | 중 | 동일 |
| 12 | **B3** Web Worker | 대규모 | 반응성 (틱 속도 아님) | 높 | 조건부 동일 |
| 13 | **B6** Tier 2 정적 타일 재우기 | 대규모 | **밀집 장면 최대 이득** | 높 | **비동일** |
| 14 | **B2** 렌더 더티 사각 | 대규모 | 렌더 −50%+ | 높 (함정 주의) | 시뮬 무관 |
| — | **B5** SIMD / **B7** 물질 VM | — | 후순위 유지 / 안 함 | — | B5는 비동일 |

한 줄 결론
`docs/WASM-ENGINE-PORTING.md`가 남긴 "dispatch 단형화"는 막연한 항목이 아니라 **구체적으로 측정 가능한 문제**였다 — 물질 객체 138개가 히든 클래스 85개로 갈려 있고, 시뮬 핫 루프는 아직 그것을 `getMaterial(id).<prop>`로 읽는 반면 **같은 저장소의 `CanvasRenderer`는 이미 256엔트리 LUT로 전부 평탄화해 두었다.** 그 규율을 `Simulation` / `SimContext` / `behaviors`로 옮기는 것(A1~A3)이 **비트동일 · 저위험 · 기존 하네스로 검증 가능한**, 지금 가장 이득/노력 비가 좋은 작업이다. 그 앞에 계측 세분화(F1~F3) 한 판만 깔면 된다.


---

## 관련 문서

- [[Web sandbox 프로젝트/구상|구상 인덱스]] — 상위 문서
- [[Web sandbox 프로젝트/구상/엔진 개선안]] — 거동을 **바꾸는** 쪽(새 물리량·새 프리미티브). 이 문서는 거동을 **안 바꾸는** 쪽이다
- [[Web sandbox 프로젝트/구상/새 물질 후보]] — 물질이 늘면 A3의 LUT 갱신 항목이 같이 늘어난다
- [[Web sandbox 프로젝트/구상/새 화학반응 공정 후보]] — `ReactionRule` 채택률이 오르면 A1의 게이팅 이득이 줄고 B7의 전제가 바뀐다
- [[Web sandbox 프로젝트/구상/UI UX 개선안]] — F4(오토세이브 히치)·B1(프레임 스킵)은 체감상 UI 문제로 나타난다
- [[Web sandbox 프로젝트/구상/시작 화면과 도감 페이지]] — 새 페이지는 PWA 셸 예열과 렌더 예산을 공유한다
- [[Web sandbox 프로젝트]] — 소개문서
- [[Web sandbox 프로젝트/가이드/물질]] — 물질 도감 가이드