# 지속 프레임 비용·투과 패스 줄이기 구현 계획 (성능 하위 프로젝트 C) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 매 프레임 꾸준히 드는 비용(DOM 레이아웃 읽기, 천천히 썰기 할당, 메인 화면 투과 패스, 천천히 썰기 등급 하향 재컴파일, 메스 칼자국 재생성)을 줄이고 S22 실기기 평균·p95 프레임 간격과 작업 시간으로 확인한다. **Architecture:** 측정 장치(프레임 간격 + 게임 루프 작업 시간)를 먼저 깔고 기준선을 잰다. 각 항목은 기존 구조 안에서 고친다: 레이아웃은 사건으로 무효화하는 기억, 할당은 재사용 객체, 투과는 A의 셰이더 보관소에 두 변형을 쥐고 보이지 않는 구간에서 투과 없는 변형으로 바꿈, 칼자국은 재질 재사용 + 버퍼 제자리 갱신. **Tech Stack:** TypeScript, Three.js 0.186, Vitest, Playwright(에뮬레이션 + Android 앱 WebView CDP), Capacitor 8, bash 3.2. **Spec:** `docs/superpowers/specs/2026-10-29-frame-cost-design.md` ## Global Constraints - 작업 브랜치 `perf-c-frame-cost`. 마무리는 로컬 main 머지까지만 하고 **푸시하지 않는다**. `PROGRESS.md`에 "main 반영·미배포"로 적는다. - 외관·판정이 바뀌지 않는다. 투과 변형 전환은 자동 화소 비교로 확인하고, 판정선을 넘으면 멈추고 시각 검수 방식을 사용자에게 묻는다. - 기존 에뮬레이션 성능 예산·품질 임계값·`FpsGovernor` 정책을 바꾸지 않는다. - 그리기 안 막힘(A 계측 `inRender + inRenderPending`)을 새로 만들지 않는다. 천천히 썰기 홈 판 PLAY와 천천히 썰기 판 등급 하향은 0이어야 한다. - `src/geometry/`·`src/stages/`는 three를 import하지 않는다. `Game`에 모드별 분기를 추가하지 않는다. - `window.__jellyDebug`에 함수를 더하면 `src/core/JellyDebug.ts`·`src/core/GameDebug.ts`·`e2e/debug-api.spec.ts`·`docs/agent-reference/debug-api.md`를 함께 갱신한다. - 셸 스크립트는 macOS bash 3.2 호환. - 실기기: Galaxy S22 Ultra 앱, medium, 강제 종료 후 처음 실행, 기준·변경 APK 번갈아 3회, 중앙값, 절대 예산 없음. 측정 전 `adb shell input keyevent KEYCODE_WAKEUP` → `adb shell svc power stayon usb`, 끝나면 성공·실패와 관계없이 `adb shell svc power stayon false`. 장면이 진행을 바꾸면 앱 Preferences를 백업·복원한다(B와 같다). - 테스트 실행 절약: 작업 중에는 그 작업의 단위 테스트 파일만, 커밋 직전 `npm test && npm run build`. E2E는 작업에 지정한 파일만, 전체 `npm run e2e:mobile`은 마감에서만. - 흔들림 판정: `docs/superpowers/refactor-baseline.md` 6절 max(1ms, 20%). - 코드 주석·커밋 메시지·문서는 한국어. 커밋 끝에 `Co-Authored-By: Claude Opus 5.5 `. 최종 독립 리뷰는 Opus / medium. ## Review Focus 1. 투과 변형 전환 경계에서 장면 섞임이 0 ↔ 양수로 오가는 프레임(천천히 썰기 판 진입·이탈 전환)에 투과 세기 1로 한 프레임 그려지거나 깜박이지 않는다. (Task 3 테스트) 2. 메인 화면 대기 중 판 선택·리플레이·상점 등으로 홈 판이 천천히 썰기 판 ↔ 일반 판으로 바뀌어도 투과 예열·변형 보관이 판 범위로 정리되고 PLAY 막힘이 생기지 않는다. (Task 3 테스트) 3. 화면 회전·창 크기 변경·주소창 높이 변화 뒤 첫 프레임의 카메라 맞춤·입력 좌표가 기억된 옛 레이아웃을 쓰지 않는다. (Task 5 테스트) 4. 메스 긋는 중 등급이 바뀌어 조각 재질이 교체되면 재사용 칼자국 재질이 새 조각 재질의 변형 유니폼을 따른다(칼자국이 옛 눌림 값으로 떠 있지 않음). (Task 2 테스트) 5. 칼자국 경로가 길어졌다가 짧아지거나(되돌려 긋기) 조각 수가 바뀌어도 버퍼의 남은 정점이 그려지지 않는다. (Task 2 테스트) ## 파일 구조 | 파일 | 책임 | 작업 | |---|---|---| | `src/core/ShaderStats.ts` | 프레임 작업 시간 기록, 구간 평균·p95 | 1 | | `e2e/helpers/framecost.ts` (신규) | C 측정 장면 6개 | 1 | | `e2e/device/framecost-device.spec.ts` (신규), `e2e/device/prefs.ts` (신규) | 실기기 기록, Preferences 백업·복원 공용 | 1 | | `scripts/device-ab.sh` | 평균·p95·작업 시간 열 | 1 | | `src/cutting/ScalpelGroove.ts`, `src/core/knife/ScalpelCutFx.ts` | 칼자국 재질 재사용·버퍼 제자리 갱신 | 2 | | `src/jelly/JellyMaterial.ts`, `src/core/ShaderKeeper.ts`, `src/core/Game.ts` | 투과 변형 전환·두 변형 보관·메인 화면 1회 예열 | 3 | | `src/core/modes/SlowMode.ts` | 등급 하향 대상 변형(유령 칼날) 보관 | 4 | | `src/core/UiLayout.ts` (신규), `src/core/Game.ts` | 레이아웃 읽기 기억·무효화 | 5 | | `src/core/modes/SlowMode.ts`, `src/core/slow/*` | 매 프레임 할당 재사용 | 6 | --- ### Task 1: 측정 장치와 실기기 기준선(0단계) **Files:** - Modify: `src/core/ShaderStats.ts`, `src/core/Game.ts`(프레임 끝에서 작업 시간 기록), `scripts/device-ab.sh`, `e2e/perf.spec.ts`, `e2e/device/cutprep-device.spec.ts`(Preferences 함수를 `prefs.ts`로 옮김), `docs/agent-reference/testing.md`, `docs/agent-reference/debug-api.md` - Create: `e2e/helpers/framecost.ts`, `e2e/device/framecost-device.spec.ts`, `e2e/device/prefs.ts` - Test: `tests/ShaderStats.test.ts` **Interfaces:** - Produces: - `ShaderStats.work(ms: number): void` — 마지막으로 기록한 프레임(`frame()`)의 작업 시간을 채운다. - `PerfWindow`에 `meanGapMs`, `p95GapMs`, `meanWorkMs`, `p95WorkMs`(구간 프레임 기준, 프레임 0개면 0) 추가. p95는 정렬 후 `ceil(0.95·n)−1` 번째 값. - `e2e/device/prefs.ts`: `snapshotPrefs(page): Promise>`, `restorePrefs(page, saved): Promise` - `e2e/helpers/framecost.ts`: `FRAMECOST_SCENARIOS: { name: string; run(page: Page, o: ScenarioOpts): Promise }[]`, `logFrameCost(info, tag, name, w, extra?)` — 한 줄 `[tag] … meanGapMs=… p95GapMs=… meanWorkMs=… p95WorkMs=… maxGapMs=… blocking=…` - `scripts/device-ab.sh` 요약 표에 `meanGapMs`·`p95GapMs`·`meanWorkMs`·`p95WorkMs` 중앙값 열(값 없으면 `-`) - [ ] **Step 1: 실패하는 테스트** — `tests/ShaderStats.test.ts`: - `구간 평균·p95 프레임 간격과 작업 시간을 낸다`: 간격 10·20·30·40ms, 작업 1·2·3·10ms 네 프레임을 `frame()`·`work()`로 넣은 뒤 `since(mark)` → `meanGapMs` 25, `p95GapMs` 40, `meanWorkMs` 4, `p95WorkMs` 10. - `프레임이 없으면 평균·p95 는 0`. - [ ] **Step 2: 실패 확인** — `npx vitest run tests/ShaderStats.test.ts` FAIL. - [ ] **Step 3: 구현** — `FrameRecord`에 `workMs`(기본 0), `since`가 간격·작업 배열을 모아 평균·p95를 계산한다. `Game.frame` 끝(렌더 제출 뒤)에서 `this.shaderStats.work(performance.now() - now)`를 부른다. - [ ] **Step 4: 통과 확인** — 같은 명령 PASS. - [ ] **Step 5: 장면 작성** — `FRAMECOST_SCENARIOS`(이름 고정, 창은 `perfMark` → 조작 → `perfSince`): - `home-slow`: 저장 진행을 천천히 썰기 판(`stage-005`, 5판)으로 두고 메인 화면에서 5초 대기. `home-normal`: 일반 판(`stage-001`)으로 같은 대기(대조군). - `slow-drag`: 5판에 `openLevel` 뒤 `slowDrag(distance 0.6, durationMs 3000)`로 썰기 끌기 구간. - `scalpel-drag`: 14판 첫 가이드를 3초에 걸쳐 끄는 메스 긋기. `spin-scalpel-hold`: 70판 가이드 절반까지 끈 뒤 3초 누른 채 멈춤(`dragHold`). - `result-card`: 1판 클리어 직후 결과 카드 등장 2초(시간 멈춤 없이). - `slow-tier-drop`: 5판에서 `requestTier('low')` 확정 뒤 10프레임(`tierDrop`과 같은 방식, `maxGapMs`·`blocking` 기록). - `slow-home-play`: `home-slow` 상태에서 PLAY 직후 30프레임(`maxGapMs`·`blocking`). 진행 저장 변경은 B의 `setProgressStage` 방식을 `framecost.ts`로 옮겨 쓴다. - [ ] **Step 6: 실기기 spec·스크립트·에뮬레이션 기록** — `framecost-device.spec.ts`의 `test.describe('프레임 비용', …)`가 장면마다 Preferences 백업·복원 후 `logFrameCost(info, 'device', …)`. `device-ab.sh` 요약에 네 열 추가. `perf.spec.ts`에 장면 1~4를 성능 두 프로젝트에서 기록만 하는 묶음 추가(새 `expect` 없음). - [ ] **Step 7: 동작 확인** — `npm run build`, `npx playwright test --project=perf-desktop-chromium --no-deps -g "프레임 비용"` 통과·`[perf]` 줄 출력. 기기에서 `npx playwright test -c playwright.device.config.ts -g "프레임 비용"`이 모든 장면을 돌고 `[device]` 줄을 남긴다. - [ ] **Step 8: 기준선과 원인 비중** — 커밋 뒤 `scripts/device-ab.sh HEAD HEAD 3 "프레임 비용"`. `summary.md`를 "측정 기록 → 기준선"에 붙인다. 이어서 실기기 Chrome 트레이스(B의 진단 방식: 브라우저 CDP `Tracing`, GPU·렌더러 범주)로 `home-slow`·`slow-drag`·`result-card`의 강제 동기 레이아웃(`Layout` 이벤트의 JS 호출 위치), GC 이벤트, 투과 패스 GPU 비중을 보고 장면별 주 원인을 한 줄씩 적는다. `slow-tier-drop`의 `blocking`이 0이면 Task 4를 "해당 없음"으로 기록한다. 데스크톱 Chrome 할당 기록(성능 패널 또는 `Profiler`/`HeapProfiler.startSampling`)으로 `slow-drag` 중 매 프레임 할당 위치 상위 목록을 적는다(Task 6 입력). - [ ] **Step 9: 문서·커밋** — `testing.md`에 묶음·지표, `debug-api.md`에 `perfSince` 새 필드를 적는다. `npm test && npm run build` 후 커밋 `C 측정 장치(평균·p95·작업 시간)와 실기기 장면 추가`. ### Task 2: 메스 칼자국 재질 재사용·버퍼 제자리 갱신 **Files:** - Modify: `src/cutting/ScalpelGroove.ts`, `src/core/knife/ScalpelCutFx.ts` - Test: `tests/ScalpelGroove.test.ts` **Interfaces:** - Produces (`ScalpelGroove.ts`): - `writeGrooveGeometry(geo: THREE.BufferGeometry, points: readonly Vec2[], y: number): void` — `grooveGeometry`와 같은 정점·인덱스를 `geo`에 쓴다. 용량이 모자라면 속성을 2배 이상으로 키워 새로 붙이고(`needsUpdate`), 남는 부분은 `setDrawRange(0, 6·(n−1))`로 그리지 않는다. `grooveGeometry(points, y)`는 새 지오메트리에 이 함수를 부르는 형태로 바꾼다. - `ScalpelGroove`는 조각·구간 순번별 항목 `{ mesh, mat }`을 재사용한다. `setPath`가 항목 수가 줄면 남는 항목만 떼고(해제는 `clear`·조각 교체 때), 재질 유니폼 `uSquash`·`uShear`·`uCenter`·`uThickness`는 매 `setPath`에서 지금 조각 재질의 것으로 다시 연결한다. - [ ] **Step 1: 실패하는 테스트** — `tests/ScalpelGroove.test.ts`: - `writeGrooveGeometry 는 grooveGeometry 와 같은 정점·인덱스를 쓴다(작은 버퍼를 키워 쓴 경우 포함)`: 3점으로 쓴 지오메트리에 다시 40점을 쓴 결과가 40점 `grooveGeometry`와 `drawRange` 안에서 `position`·`aN`·`aSide`·`aAlong`·인덱스가 같다. - `경로가 짧아지면 drawRange 가 줄어 남은 정점을 그리지 않는다`: 40점 → 5점 → `drawRange.count === 24`. - `setPath 를 거듭해도 같은 조각 구간의 재질·메시를 재사용하고 해제하지 않는다`: 같은 조각에 경로를 3번 바꿔 `setPath` → 메시 객체 동일, 재질 `dispose` 0회. - `조각 재질이 바뀌면 재사용 칼자국 재질이 새 변형 유니폼을 따른다`: `setPath` → 조각 `replaceMaterial(새 재질)` → `setPath` → 칼자국 재질 `uniforms.uSquash`가 새 재질 `jelly.uSquash`와 같은 객체. - 기존 테스트(`clear 는 리본을 떼어 내고 해제한다` 등)는 그대로 통과해야 한다. - [ ] **Step 2: 실패 확인** — `npx vitest run tests/ScalpelGroove.test.ts` FAIL. - [ ] **Step 3: 구현** — 위 인터페이스대로. `ScalpelCutFx.follow`의 `sp.points.map(...)`은 재사용 배열로 바꾼다. `clipPolylineInside` 결과 할당은 이 작업 범위 밖이다. - [ ] **Step 4: 통과 확인** — `npx vitest run tests/ScalpelGroove.test.ts tests/ScalpelPath.test.ts` PASS, `npm run build`, `npx playwright test e2e/scalpel.spec.ts e2e/shader-compile.spec.ts --project=desktop-chromium` PASS. - [ ] **Step 5: 커밋** — `npm test && npm run build`, 커밋 `메스 칼자국 재질을 재사용하고 버퍼를 제자리에서 갱신`. ### Task 3: 메인 화면 투과 패스 — 보이지 않는 구간의 투과 없는 변형 전환 **Files:** - Modify: `src/jelly/JellyMaterial.ts`, `src/core/ShaderKeeper.ts`, `src/core/Game.ts`, `src/core/GameDebug.ts`, `src/core/JellyDebug.ts`, `e2e/debug-api.spec.ts`, `docs/agent-reference/debug-api.md`, `docs/agent-reference/rendering.md` - Test: `tests/JellyMaterial.test.ts`, `tests/ShaderKeeper.test.ts`, `e2e/shader-compile.spec.ts`, `e2e/slow-view.spec.ts` **Interfaces:** - Produces: - `isSlowTransmissive(mat: JellyMaterial): boolean` — 투과 재질 여부(`uSlowTransK` 유니폼 존재). `transmission` 값으로 가리지 않는다. - `setSlowTransmission(mat, k)`: 투과 재질이면 `k <= 0`일 때 `transmission = 0`(투과 없는 변형), `k > 0`이면 `max(SLOW_TRANS_MIN, min(1, k))`. `uSlowTransK`는 같은 값. - `jellyKeepKey`에 `tr${mat.transmission > 0 ? 1 : 0}` 추가. - `ShaderKeeper.keepJelly`: 투과 재질이면 투과 켬(1)·끔(0) 두 변형을 함께 쥔다. - `Game`: 메인 화면 진입(`start`의 메뉴 경로, `showMenu`) 뒤 첫 프레임 하나는 투과 재질 조각을 `k = SLOW_TRANS_MIN`으로 그려 투과 패스를 한 번 돌린다(렌더 타깃용 변형 예열). 그 뒤 `k = sceneK`. - 디버그 `forceSlowTransmission(on: boolean | null)`: 참이면 메인 화면에서도 옛 동작(`k = SLOW_TRANS_MIN`)으로 그린다(화소 비교용). `null`이면 해제. - [ ] **Step 1: 실패하는 테스트** - 단위: `setSlowTransmission 은 k 0 에서 투과를 0 으로, 양수에서 하한 이상으로 둔다`, `isSlowTransmissive 는 투과를 0 으로 내린 재질도 투과 재질로 본다`, `jellyKeepKey 는 투과 켬·끔을 다른 키로 본다`, `keepJelly 는 투과 재질의 두 변형을 함께 쥔다`(가짜 대상, `has` 두 키 참), `투과 아닌 재질은 한 변형만 쥔다`. - E2E(`shader-compile.spec.ts`, 데스크톱·모바일): `천천히 썰기 홈 판에서 PLAY 막힘 0`(진행 `stage-005` 주입 → 메인 → PLAY → 30프레임 `blocking === 0`), `천천히 썰기 판 진입·이탈 전환 막힘 0`(4→5, 5 결과 → 6). - E2E(`slow-view.spec.ts`): `메인 화면 투과 없는 변형은 옛 투과 하한 화면과 화소가 같다` — 진행 `stage-005`, 메인, `setTimeScale(0)`, 캔버스 스크린샷 A(`forceSlowTransmission(true)` + `renderNow`)와 B(`forceSlowTransmission(null)` + `renderNow`)를 비교해 채널 차이 3/255 초과 화소가 0.1% 이하. - [ ] **Step 2: 실패 확인** — 단위 FAIL. E2E는 빌드 뒤 함께(Step 4). - [ ] **Step 3: 구현** — 위 인터페이스대로. 장면 섞임이 0 ↔ 양수로 바뀌는 프레임에도 `Game.frame`의 렌더 직전 일괄 설정(지금 위치)만 쓴다. `SlowMode.beforeRender`의 미리 만든 조각 설정도 같은 함수다. 예열 프레임 플래그는 메인 진입마다 1로 둔다. - [ ] **Step 4: 통과 확인** — 단위 PASS, `npm run build`, `npx playwright test e2e/shader-compile.spec.ts e2e/slow-view.spec.ts e2e/slow-flow.spec.ts e2e/debug-api.spec.ts --project=desktop-chromium` PASS. 화소 비교가 판정선을 넘으면 멈추고 사용자에게 알린다. - [ ] **Step 5: 커밋** — `npm test && npm run build`, 커밋 `메인 화면 투과 패스 제거: 보이지 않는 구간은 투과 없는 변형으로 전환`. ### Task 4: 천천히 썰기 등급 하향 재컴파일 (기준선 막힘 > 0일 때만) **Files:** - Modify: `src/core/modes/SlowMode.ts` - Test: `tests/ModeShaderKeep.test.ts`, `e2e/shader-compile.spec.ts` **Interfaces:** - Consumes: `ShaderKeeper.keep(key, scope, make)`, `slowTransmission(tier)`, `SlowModeDeps.knifeGhost` - Produces: `SlowMode.keepShaders(level: LevelDefinition, tier: QualityTierLevel): Promise` — `!slowTransmission(tier)`이면 유령 칼날 재질(복제)을 키 `fx:knife-ghost:${tier}`로 쥔다. A의 `requestTier`(보관 뒤 확정)가 `keepLevelShaders`를 거쳐 이 훅을 부르므로 하향 전에 컴파일된다. 기준선 트레이스에서 다른 low 전용 자원이 보이면 같은 훅에 더한다. - [ ] **Step 0: 조건 확인** — Task 1 기준선 `slow-tier-drop`의 `blocking` 중앙값이 0이면 이 작업을 건너뛰고 원장에 "해당 없음"을 적는다. - [ ] **Step 1: 실패하는 테스트** — `ModeShaderKeep.test.ts` `SlowMode.keepShaders 는 low 에서 유령 칼날 재질을 쥐고 medium 에서는 쥐지 않는다`. `shader-compile.spec.ts` `셰이더 막힘 0: 등급 하향(5판)`(`tierDrop(page, 5, …)`, `blocking === 0`). - [ ] **Step 2: 실패 확인** — 단위 FAIL(E2E는 데스크톱에서 재현되지 않을 수 있다. 재현되지 않으면 원장에 적고 단위·실기기로 확인). - [ ] **Step 3: 구현** — 위 인터페이스대로. - [ ] **Step 4: 통과 확인** — 단위 PASS, `npm run build`, 해당 E2E PASS. - [ ] **Step 5: 커밋** — `npm test && npm run build`, 커밋 `천천히 썰기 등급 하향 전에 low 전용 무대 셰이더를 보관`. ### Task 5: DOM 레이아웃 읽기 기억 **Files:** - Create: `src/core/UiLayout.ts` - Modify: `src/core/Game.ts`(`fitCamera`, `jellyToScreen`·`plateToScreen`·`showJudge`·입력 변환·`render.canvasRect`, `onResize`, 메뉴·결과 카드·패널·HUD 표시 전환) - Test: 신규 `tests/UiLayout.test.ts`, `e2e/layout.spec.ts`, `e2e/tablet.spec.ts` **Interfaces:** - Produces (`UiLayout.ts`, DOM 타입만 사용): - `export class UiLayout { constructor(read: () => UiRects, observe?: (cb: () => void) => () => void); get(): UiRects; invalidate(): void; dispose(): void }` - `export interface UiRects { canvas: DOMRect; hudTop: DOMRect | null; menuTitle: DOMRect | null; menuCard: DOMRect | null; knifeDock: DOMRect | null; hudHint: DOMRect | null }` - `get()`은 무효화 뒤 처음 부를 때만 `read()`를 부른다. `observe`(기본: `ResizeObserver`로 캔버스·다섯 요소 관찰)가 크기 변화를 알리면 무효화한다. - `Game`은 `onResize`, `visualViewport` resize, 메뉴 표시·숨김, 결과 카드 표시·숨김, HUD 표시 전환, 패널 열고 닫기, 레벨 불러오기에서 `invalidate()`를 부른다. 디버그 좌표 API는 지금처럼 직접 읽어도 된다. - [ ] **Step 1: 실패하는 테스트** — `tests/UiLayout.test.ts`: - `무효화 전에는 같은 값을 돌려주고 read 를 다시 부르지 않는다`(호출 수 1). - `invalidate 뒤 처음 get 에서 다시 읽는다`. - `관찰자가 크기 변화를 알리면 무효화된다`(가짜 `observe`). - `dispose 는 관찰을 끊는다`. - E2E(`layout.spec.ts`, 모바일): `화면 회전·크기 변경 직후 첫 프레임의 젤리 화면 좌표가 직접 읽은 값과 같다` — `page.setViewportSize`로 세로 → 가로 → 세로, 매번 `nextFrames(1)` 뒤 `jellyScreenBounds()`가 `nextFrames(10)` 뒤 값과 1px 안. - [ ] **Step 2: 실패 확인** — 단위 FAIL(E2E는 빌드 뒤). - [ ] **Step 3: 구현** — 위 인터페이스대로. `fitCamera`·화면 좌표 변환은 `uiLayout.get()`을 쓴다. - [ ] **Step 4: 통과 확인** — 단위 PASS, `npm run build`, `npx playwright test e2e/layout.spec.ts e2e/tablet.spec.ts e2e/home.spec.ts e2e/slow-view.spec.ts --project=mobile-chromium` PASS. - [ ] **Step 5: 커밋** — `npm test && npm run build`, 커밋 `HUD·메뉴·캔버스 레이아웃 읽기를 사건으로 무효화하는 기억으로`. ### Task 6: 천천히 썰기 매 프레임 할당 재사용 **Files:** - Modify: Task 1 Step 8 목록의 파일(`src/core/modes/SlowMode.ts`, `src/core/slow/*` 중) - Test: 해당 모듈의 기존 테스트(`tests/SlowGameMode.test.ts` 등)와 바꾼 함수별 신규 동치 테스트 - [ ] **Step 1: 대상 확정** — Task 1 Step 8 할당 상위 목록 중 천천히 썰기 매 프레임 경로(`beforeRender`·`afterPieces`·미리보기·느낌·막대 갱신)의 항목을 원장에 적는다. 항목이 없으면 이 작업을 "해당 없음"으로 기록하고 건너뛴다. - [ ] **Step 2: 실패하는 테스트** — 바꿀 함수마다 `재사용 객체로 바꾼 <함수> 는 이전 구현과 같은 값을 낸다`(테스트 안에 이전 구현을 기준으로 보존하고 같은 입력 시퀀스로 비교) 와 `받는 쪽이 재사용 객체를 저장하지 않는다`(두 번 부른 결과의 앞 값이 덮여도 소비자 상태가 같음). 재사용 전환이 결과를 바꾸지 않는 경우 테스트가 처음부터 통과할 수 있다. 그때는 할당 횟수를 세는 테스트(`vi.spyOn` 대신 생성 함수 주입 또는 반환 객체 동일성 `toBe`)로 RED를 만든다. - [ ] **Step 3: 구현** — 재사용 객체·배열로 바꾼다. - [ ] **Step 4: 통과 확인** — 해당 단위 PASS, `npm run build`, `npx playwright test e2e/slow-flow.spec.ts e2e/slow-feel.spec.ts e2e/slow-judge.spec.ts --project=mobile-chromium` PASS. - [ ] **Step 5: 커밋** — `npm test && npm run build`, 커밋 `천천히 썰기 매 프레임 할당을 재사용 객체로`. ### Task 7: 마감 검증·실기기 A/B·문서·머지 **Files:** - Modify: `PROGRESS.md`, `docs/agent-reference/{rendering,architecture,modes,testing}.md`, 이 계획의 "측정 기록", 설계 문서 상태 줄 - [ ] **Step 1: 전체 검증** — `npm test`, `npm run build`, `npm run e2e:mobile`(0 실패). - [ ] **Step 2: 에뮬레이션 성능 A/B** — B와 같은 절차로 Task 1 커밋 대 마지막 커밋, 데스크톱 → 모바일 3회. 회귀 후보는 번갈아 최대 8회. - [ ] **Step 3: 실기기 A/B** — `scripts/device-ab.sh HEAD 3 "프레임 비용"`. 설계 6.2 조건을 장면별로 판정한다(장면 1~4: 간격 악화 없음 + 간격 또는 작업 시간 개선, 5: 막힘 0·최대 간격 개선 또는 해당 없음, 6: 막힘 0·악화 없음). 못 지킨 장면은 원인과 함께 머지 전에 사용자에게 알린다. - [ ] **Step 4: 문서** — `PROGRESS.md`에 "성능 하위 프로젝트 C(…, main 반영·미배포)" 절과 표, "다음 할 일" 갱신. 상세 문서 갱신. - [ ] **Step 5: 최종 독립 리뷰** — Opus / medium. - [ ] **Step 6: 머지** — 사용자 확인 뒤 main으로 `--no-ff` 머지. **푸시하지 않는다.** --- ## 측정 기록 ### 기준선 (Task 1) `scripts/device-ab.sh f9c7ee6 f9c7ee6 3 "프레임 비용"`(같은 APK 번갈아 3회씩, 표본 6개), 결과 폴더 `.playwright-mcp/device-ab/20261029-142733`, 실패 없음. | 순간 | APK | n | maxGapMs | blocking | loadMs | readyMs | prepMs | meanGapMs | p95GapMs | meanWorkMs | p95WorkMs | |---|---|---|---|---|---|---|---|---|---|---|---| | home-normal | base | 3 | 11.6 | 0.0 | - | - | - | 8.3 | 8.7 | 0.9 | 1.5 | | home-normal | head | 3 | 11.2 | 0.0 | - | - | - | 8.3 | 8.7 | 0.9 | 1.5 | | home-slow | base | 3 | 16.6 | 0.0 | - | - | - | 8.3 | 8.7 | 1.5 | 2.4 | | home-slow | head | 3 | 19.0 | 0.0 | - | - | - | 8.3 | 8.8 | 1.5 | 2.3 | | result-card | base | 3 | 21.5 | 0.0 | - | - | - | 8.5 | 9.0 | 1.0 | 1.3 | | result-card | head | 3 | 29.6 | 0.0 | - | - | - | 8.5 | 8.8 | 1.1 | 1.2 | | scalpel-drag | base | 3 | 37.1 | 0.0 | - | - | - | 8.4 | 9.3 | 1.0 | 1.5 | | scalpel-drag | head | 3 | 32.7 | 0.0 | - | - | - | 8.4 | 9.3 | 1.0 | 1.6 | | slow-drag | base | 3 | 17.3 | 0.0 | - | - | - | 8.3 | 8.8 | 2.4 | 3.1 | | slow-drag | head | 3 | 18.1 | 0.0 | - | - | - | 8.3 | 8.8 | 2.4 | 3.1 | | slow-home-play | base | 3 | 76.1 | 9.0 | - | - | - | 11.3 | 25.9 | 5.5 | 9.0 | | slow-home-play | head | 3 | 77.5 | 9.0 | - | - | - | 11.2 | 27.7 | 5.5 | 9.3 | | slow-tier-drop | base | 3 | 110.5 | 8.0 | - | - | - | 16.7 | 98.4 | 11.3 | 97.4 | | slow-tier-drop | head | 3 | 128.1 | 8.0 | - | - | - | 17.2 | 128.1 | 11.5 | 126.8 | | spin-scalpel-hold | base | 3 | 20.8 | 0.0 | - | - | - | 8.3 | 9.1 | 1.8 | 3.1 | | spin-scalpel-hold | head | 3 | 16.4 | 0.0 | - | - | - | 8.3 | 9.1 | 1.7 | 2.6 | 판독: - S22 앱은 120Hz(프레임 8.3ms)로 돌고, 대부분 장면의 p95 간격이 8.7~9.3ms라 이미 화면 주사율에 묶여 있다. 게임 루프 작업 시간은 1~2.4ms(p95 1.2~3.1ms)로 작다. 장면 1~4의 개선은 작업 시간으로만 드러날 가능성이 크다(설계 6.2 보조 지표). - 메인 화면: 천천히 썰기 홈 작업 1.5ms / 일반 홈 0.9ms. 차이 약 0.6ms가 투과 패스(CPU 쪽) 비용으로 보인다 → Task 3. - `slow-home-play`: 그리기 안 막힘 9, 최대 76~78ms(기존 문제) → Task 3에서 0으로. - `slow-tier-drop`: 그리기 안 막힘 8, 최대 110~128ms → Task 4 진행(해당 없음 아님). 유령 칼날 외 원인도 있을 수 있어 Task 4에서 WebGL 기록으로 확인한다. - 칼자국(`scalpel-drag` 작업 1.0ms, `spin-scalpel-hold` 1.7~1.8ms), 천천히 썰기 끌기(2.4ms), 결과 카드(1.0ms)는 비용이 작다. 원인 비중 트레이스(강제 레이아웃·GC)는 작업 시간이 이 정도라 생략하고, Task 5·6은 데스크톱 할당·레이아웃 계측 근거로 진행한다(Ruling). ### A/B (Task 7) `scripts/device-ab.sh f9c7ee6 aa55024 3 "프레임 비용"`, 결과 폴더 `.playwright-mcp/device-ab/20261029-151630`, 실패 없음. | 순간 | APK | n | maxGapMs | blocking | loadMs | readyMs | prepMs | meanGapMs | p95GapMs | meanWorkMs | p95WorkMs | |---|---|---|---|---|---|---|---|---|---|---|---| | home-normal | base | 3 | 16.6 | 0.0 | - | - | - | 8.3 | 8.6 | 0.9 | 1.2 | | home-normal | head | 3 | 9.9 | 0.0 | - | - | - | 8.3 | 8.6 | 0.8 | 1.1 | | home-slow | base | 3 | 11.2 | 0.0 | - | - | - | 8.3 | 8.6 | 1.4 | 2.2 | | home-slow | head | 3 | 11.2 | 0.0 | - | - | - | 8.3 | 8.9 | 1.3 | 2.0 | | result-card | base | 3 | 101.7 | 0.0 | - | - | - | 8.8 | 8.8 | 1.4 | 1.3 | | result-card | head | 3 | 93.3 | 0.0 | - | - | - | 9.2 | 10.2 | 1.0 | 1.3 | | scalpel-drag | base | 3 | 38.7 | 0.0 | - | - | - | 8.4 | 9.2 | 1.0 | 1.5 | | scalpel-drag | head | 3 | 33.1 | 0.0 | - | - | - | 8.4 | 9.0 | 1.0 | 1.4 | | slow-drag | base | 3 | 32.3 | 0.0 | - | - | - | 8.4 | 8.8 | 2.4 | 3.3 | | slow-drag | head | 3 | 17.5 | 0.0 | - | - | - | 8.4 | 8.8 | 2.5 | 3.1 | | slow-home-play | base | 3 | 77.8 | 9.0 | - | - | - | 11.8 | 27.2 | 5.4 | 8.5 | | slow-home-play | head | 3 | 53.7 | 0.0 | - | - | - | 11.4 | 27.2 | 4.5 | 6.1 | | slow-tier-drop | base | 3 | 134.2 | 8.0 | - | - | - | 21.2 | 134.2 | 16.2 | 132.3 | | slow-tier-drop | head | 3 | 117.8 | 0.0 | - | - | - | 15.1 | 22.3 | 9.0 | 17.8 | | spin-scalpel-hold | base | 3 | 16.6 | 0.0 | - | - | - | 8.3 | 9.0 | 1.7 | 2.6 | | spin-scalpel-hold | head | 3 | 15.2 | 0.0 | - | - | - | 8.3 | 8.9 | 1.4 | 2.1 | 설계 6.2 판정(흔들림 판정 max(1ms, 20%)): | 장면 | 결과 | |---|---| | home-slow(1) | 간격 악화 없음(p95 8.6 → 8.9), 작업 시간 1.4 → 1.3ms(평균)·2.2 → 2.0(p95): **개선이 판정선 안**. 일반 홈과의 차이(약 0.5ms)도 그대로라 투과 패스 제거 효과는 CPU 작업 시간으로 드러나지 않는다(GPU 쪽은 이 지표로 재지 못함) | | slow-drag(2) | 간격·작업 시간 변화 없음(작업 2.4 → 2.5). 최대 간격 32.3 → 17.5 | | scalpel-drag·spin-scalpel-hold(3) | 작업 1.0 → 1.0 / 1.7 → 1.4(평균), 판정선 안 | | result-card(4) | 작업 1.4 → 1.0(평균), p95 간격 8.8 → 10.2(흔들림 폭 1.76 안). **판정선 안** | | slow-tier-drop(5) | **충족**: 막힘 8 → 0, p95 간격 134 → 22, 최대 간격 134 → 118(전환 프레임 자체의 재질 교체 비용이 남음) | | slow-home-play(6) | **충족**: 막힘 9 → 0, 최대 간격 77.8 → 53.7 | 장면 1~4는 기준선부터 120Hz 주사율에 묶여 있고(p95 8.6~9.3ms) 작업 시간이 1~2.5ms라, 고친 항목(투과 패스·칼자국·캔버스 위치)의 효과가 판정선(1ms)을 넘지 않는다. 설계 6.2에 따라 머지 전에 사용자에게 알린다. ### 에뮬레이션 성능 A/B (Task 7) `f9c7ee6` 대 `59b8a64`, 데스크톱 → 모바일 성능 프로젝트 3회(`.playwright-mcp/perf-ab-c-20261029-152919`). 중앙값 차가 max(1ms, 20%)를 넘은 후보 10개(21→22·4→5 loadMs, 압착·찾기 전환 최대 간격, 1판 하향, 모바일 27판 획 처리·75판 획 처리·slow-drag 최대 간격)를 번갈아 8회 다시 쟀다(`perf-ab-c-cand-155226`). - 데스크톱 후보는 모두 판정선 안으로 돌아왔다(예: 21→22 loadMs 1.1 → 1.1, 압착 결과 카드 최대 간격 21.1 → 19.9). - 모바일 27판 획 처리(8.6 → 13.2)와 slow-drag 최대 간격(104 → 151)이 남았다. 두 값이 같은 회차에서 함께 튀었다(변경 2·3·4·8회차, 기준 7회차). 절단 준비 성능 테스트는 셰이더 보관을 기다리지 않고 칼질하므로, 모든 판 보관에 붙은 그림자 깊이 예열(`compileDepthAsync`)이 첫 획과 겹친다는 가설을 따로 확인했다. - 가설 실험: 기준·변경·변경에서 깊이 예열만 끈 빌드를 번갈아 9회(같은 테스트 파일). 27판 획 처리 중앙값 12.1 / 12.4 / 13.1ms, slow-drag 최대 간격 156 / 159 / 155ms. 세 빌드가 같은 흔들림 폭이고 깊이 예열을 꺼도 바뀌지 않는다 → **회귀 아님**(회차 단위 기계 흔들림). - 단언 실패: 3회 묶음의 75판(데스크톱 8.3ms)·58판(12.7ms)·27판·37판(모바일) 준비 8ms 초과는 각 판 한 회차씩이고 다른 회차는 정상이다. 75판 모바일 획 처리 20ms 초과는 후보 재측정에서 기준(2·5·8회차)도 넘었다. 기존 경계 흔들림이다(PROGRESS 성능 경계). 임계값은 바꾸지 않았다. - 개선: 모바일 찾기 곰 누름 최대 간격 86.8 → 20.4ms.