# 절단 준비 CPU·판 전환 끊김 줄이기 구현 계획 (성능 하위 프로젝트 B) > **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:** 자르는 순간과 판이 바뀌는 순간에 몰리는 CPU 계산(조각 SDF, 메시 코어, 곡선 첫 가이드, 압착 장비 모델·곰 젤리 준비)을 줄이거나 다른 프레임으로 옮겨, S22 실기기 전·후 측정으로 개선을 확인한다. **Architecture:** 계산량 자체를 줄이고(SDF 행 단위 포함 판정, 메시 코어 기억) 남는 덩어리는 기존 준비 통로(`Game.prepareTable`, `ModePrep`, 홈 화면 프레임)에서 생성기를 프레임마다 돌려 나눈다. 압착 장비 모델은 Game이 소유해 판 사이에 재사용한다. 출력(SDF·메시·가이드·외관)은 바뀌지 않는다. **Tech Stack:** TypeScript, Three.js 0.186, Vitest, Playwright(에뮬레이션 + Android 앱 WebView CDP), Capacitor 8, bash 3.2. **Spec:** `docs/superpowers/specs/2026-10-29-cut-prep-cpu-design.md` ## Global Constraints - 작업 브랜치 `perf-b-cut-prep`. 마무리는 로컬 main 머지까지만 하고 **푸시하지 않는다**. `PROGRESS.md`에 "main 반영·미배포"로 적는다. - 외관·채점·절단 판정 결과가 바뀌지 않는다. SDF는 모든 표본이 기존과 비트 단위로 같다(`Object.is`). 같게 만들 수 없으면 멈추고 사용자에게 허용 오차를 묻는다. - 기존 에뮬레이션 성능 예산(`e2e/perf.spec.ts`)과 메시·퍼즈 품질 임계값을 완화하지 않는다. - 입력 처리 중 동기 메시 생성 금지(`inputMeshBuilds` 0), 준비 자원의 인계·취소·해제 책임 유지. - `src/geometry/`·`src/stages/`는 three를 import하지 않는다. - `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회, 중앙값 기록, 절대 예산 없음. Android 에뮬레이터 사용 금지. - 실기기 측정 전 `adb shell input keyevent KEYCODE_WAKEUP` → `adb shell svc power stayon usb`, 끝나면 성공·실패와 관계없이 `adb shell svc power stayon false`. - 테스트 실행 절약: 작업 중에는 그 작업의 단위 테스트 파일만, 커밋 직전 `npm test && npm run build` 한 번. E2E는 Task 1(측정 도입)·Task 5(홈 경로 변경)·Task 7(위험한 모드 준비 변경)에서 지정한 파일만, 전체는 Task 8(마감)에서만. 리뷰어는 구현자 출력을 보고 의심이 있을 때만 해당 파일 하나를 돌린다. - 코드 주석·커밋 메시지·문서는 한국어. 커밋 끝에 `Co-Authored-By: Claude Opus 5.5 `. - 최종 독립 리뷰는 Opus / medium. ## Review Focus 1. 결과 카드 중 압착 모델을 준비한 뒤 장착 장비가 바뀜(디버그 `equip`, 컨텍스트 손실 복구) → 옛 모델은 해제되고 진입한 판은 새 장비 모델을 쓴다. (Task 6 테스트) 2. 곰 압착·찾기 준비가 끝나기 전에 Next → 진입 뒤 이어서 끝나 `ready`가 되고, 그 사이 품질 등급이 바뀌었으면 현재 등급 재질이다. 이어서 하던 중 Home·다른 판으로 가면 만든 자원이 모두 해제된다. (Task 7 테스트) 3. 홈 화면에서 가이드 계산이 끝나기 전 즉시 PLAY → 가이드가 동기 계산과 같은 점열이고 튜토리얼 손가락도 같은 경로다. (Task 5 테스트) 4. 홈에서 가이드 계산 중 판 선택(`onJump`·`onReplay`)이나 상점으로 다른 판을 불러옴 → 이전 판의 미룬 계산이 멈추고 새 판 가이드와 섞이지 않는다. (Task 5 테스트) 5. 재사용 압착 모델로 두 번째 판에 들어감 → 접촉판 자세·누름 깊이·연출 상태가 새 모델과 같다(이전 판 마지막 눌림이 남지 않음). (Task 6 테스트) ## 파일 구조 | 파일 | 책임 | 작업 | |---|---|---| | `e2e/helpers/cutprep.ts` (신규) | B 측정 장면(절단·찍기·전환·홈 경로) 공용 정의 | 1 | | `e2e/device/cutprep-device.spec.ts` (신규) | 실기기 B 장면 기록(단언 없음) | 1 | | `scripts/device-ab.sh` | 측정 묶음 선택 인자, `loadMs`·`readyMs` 중앙값 요약 | 1 | | `e2e/perf.spec.ts` | 압착·찾기 전환 기록(단언 없음) | 1 | | `src/geometry/Polygon.ts` | 행 단위 짝홀 교차표 `rowCrossings` | 2 | | `src/geometry/PieceSdf.ts` | 오목 원본 포함 판정을 행 교차표로 | 2 | | `src/geometry/JellyMeshBuilder.ts` | `buildMeshCore` 기억 | 3 | | `src/stages/LevelDefinitions.ts` | 첫 가이드 단계 계산 `stepGuideWarm`·`stepGuideReady` | 4 | | `src/core/Game.ts` | 준비 중 첫 가이드 분할, 홈 경로 가이드 미룸, 압착 모델 소유 | 4·5·6 | | `src/stages/StageSession.ts` | 가이드 지연 생성 | 5 | | `src/core/modes/KnifeMode.ts` | 튜토리얼 경로 지연 | 5 | | `src/core/modes/GameMode.ts` | `ModeHost` 압착 모델 빌림 인터페이스 | 6 | | `src/core/crush/CrushScene.ts`, `src/core/odd/OddJellyScene.ts` | 모델 해제 대신 떼기 | 6 | | `src/core/crush/PressJellyPrep.ts` (신규) | 압착·찾기 공용 준비 작업(결과 카드 시작, 진입 인계·이어 하기) | 7 | | `src/core/modes/BearCrushMode.ts`, `src/core/modes/OddJellyMode.ts` | `ModePrep` 구현·`enter` 인계 | 6·7 | --- ### Task 1: 측정 장면 추가와 실기기 기준선(0단계) **Files:** - Create: `e2e/helpers/cutprep.ts`, `e2e/device/cutprep-device.spec.ts` - Modify: `scripts/device-ab.sh`, `e2e/perf.spec.ts`, `docs/agent-reference/testing.md` - Record: 이 계획 문서 끝 "측정 기록" 절 **Interfaces:** - Consumes: `markPerf`·`perfSince`·`logPerf`·`holdTier`·`waitShadersReady`(`e2e/helpers/shader.ts`), `openLevel`·`cutAlong`·`cutAlongPath`·`stampAt`·`guides`·`nextFrames`(`e2e/helpers/common.ts`), 압착·찾기·휘둘러 베기 도우미(`e2e/helpers/`), 진행 주입(`e2e/helpers/progress.ts`), 디버그 `loadMs()`·`phase()`·`bearCrush()`·`oddJelly()`·`equip(id)`. - Produces: - `export interface CutPrepRecord { name: string; windows: Record; loadMs?: number; readyMs?: number }` - `export const CUTPREP_SCENARIOS: { name: string; run(page: Page, o: ScenarioOpts): Promise }[]` - `export function logCutPrep(info: TestInfo, tag: string, r: CutPrepRecord): void` — 창마다 한 줄 `[device] : apk=… maxGapMs=… loadMs=… readyMs=…` (창 이름: `cut`, `card`, `entry`, `total`, `home`, `menu-return`, `play`) - `scripts/device-ab.sh [runs=3] [grep="셰이더 순간"]`, 요약 표에 `loadMs`·`readyMs` 중앙값 열 추가(값이 없으면 `-`) - [ ] **Step 1: 장면 정의 작성** — `CUTPREP_SCENARIOS`에 다음 이름으로 만든다. 레벨 번호는 표시 순서다. - `concave-cut`: 원본 외곽이 오목한 칼·메스 판 하나(후보를 `isConvex(getJellyShape(t).outline)`로 골라 이 단계 기록에 적는다)에서 첫 가이드 절단. 창 `cut`(손 떼기 전 표식 ~ 교체 뒤 10프레임). - `stamp`: 45판 찍기 한 번. 창 `cut`. - `curve-transition`: 74판 클리어 → 결과 카드(자동 진행 대기) → 75판 착지. 창 `card`(결과 카드 표시 ~ 다음 판 `loadLevel` 시작 직전), `entry`(~ 착지 뒤 30프레임), `total`, `loadMs`. - `curve-transition-quick`: 위와 같되 결과 카드가 뜨면 바로 Next. - `crush-transition` / `crush-transition-heavy`: 58판 클리어 → 59판 `bearCrush().ready`. heavy는 시작 전 디버그 `equip`으로 가장 무거운 압착 장비(`robot-fist`·`marshmallow-hammer`·`cat-paw` 중 `createEquipmentModel` 시간이 가장 긴 것을 Node에서 재서 고르고 기록)를 장착. 창 `card`·`entry`·`total`, `loadMs`, `readyMs`(Next 입력 ~ `ready`). - `crush-transition-quick`: 결과 카드 즉시 Next. - `odd-transition`: 65판 클리어 → 66판 `oddJelly().ready`. 지표는 crush와 같다. - `home-curve`: 진행을 75판으로 주입하고 새로 불러와 홈 표시(창 `home`: 홈 표시 후 3초, `loadMs`) → PLAY(창 `play`: PLAY ~ 30프레임). 기기에서 진행 주입이 저장소 차이로 안 되면 75판 플레이 중 Home으로 홈 복귀하는 경로로 대체하고 그 사실을 기록한다. - `home-return`: 홈 판이 지금 판과 다른 상태(예: 진행 75판, 74판 리플레이)에서 Home. 창 `menu-return`(Home 입력 ~ 30프레임). - [ ] **Step 2: 실기기 spec 작성** — `cutprep-device.spec.ts`의 `test.describe('절단 준비 순간', …)`가 장면마다 테스트 하나를 만들고 `logCutPrep(info, 'device', r)`를 부른다. 단언하지 않는다. 시작 전 `holdTier`로 자동 하향을 멈춘다. - [ ] **Step 3: `device-ab.sh` 일반화** — 네 번째 인자로 `-g` 값을 받는다(기본 `"셰이더 순간"`, 기존 동작 유지). 요약 awk가 `loadMs=`·`readyMs=`도 중앙값을 낸다. - [ ] **Step 4: 에뮬레이션 기록** — `perf.spec.ts`에 `crush-transition`·`odd-transition`을 데스크톱·모바일 성능 프로젝트에서 돌려 `[perf]` 줄만 남기는 테스트를 추가한다(새 `expect` 없음). - [ ] **Step 5: 동작 확인** — `npx playwright test --project=perf-desktop-chromium --no-deps -g "전환"`이 통과하고 `[perf]` 줄에 `maxGapMs`·`loadMs`·`readyMs`가 찍힌다. 기기에서 `npx playwright test -c playwright.device.config.ts -g "절단 준비 순간"`이 모든 장면을 돌고 `[device]` 줄을 남긴다. - [ ] **Step 6: 기준선 측정** — 이 작업 커밋 뒤 `scripts/device-ab.sh HEAD HEAD 3 "절단 준비 순간"`. `summary.md`를 이 문서 끝 "측정 기록 → 기준선"에 붙이고, 장면별로 가장 큰 끊김이 어느 항목(SDF·코어·가이드·모델·젤리 준비)에서 오는지 한 줄씩 적는다(Chrome DevTools 성능 기록이나 `prepMaxMs`로 확인). - [ ] **Step 7: 문서·커밋** — `docs/agent-reference/testing.md`의 실기기 절에 새 묶음과 인자를 적는다. `npm test && npm run build` 후 커밋 `B 측정 장면과 실기기 A/B 묶음 선택 추가`. ### Task 2: 조각 SDF 오목 원본 포함 판정을 행 단위로 **Files:** - Modify: `src/geometry/Polygon.ts`, `src/geometry/PieceSdf.ts` - Test: `tests/PieceSdf.test.ts`, `tests/Polygon.test.ts` **Interfaces:** - Produces: `export function rowCrossings(poly: readonly Vec2[], y: number, out: number[]): number` (`Polygon.ts`) — `pointInPolygon`과 같은 식 `((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x`로 `a.y > y !== b.y > y`인 변의 교차 x를 `out`에 오름차순으로 채우고 개수를 돌려준다. 점 `(x, y)`가 안이면 `out` 중 `x <` 인 값(= `x < xi`)의 개수가 홀수다. - [ ] **Step 1: 실패하는 테스트** - `Polygon.test.ts` `rowCrossings 짝홀 판정이 pointInPolygon과 같다`: 무작위 오목 다각형 200개 × 행 64개 × 표본 64개에서 `(count of xi with x < xi) % 2 === 1`이 `pointInPolygon`과 모두 같다. 꼭짓점 y와 정확히 같은 행을 반드시 포함한다(꼭짓점 y를 행 값으로 직접 넣는다). - `PieceSdf.test.ts` `오목 원본 행 단위 판정이 기존 굽기와 비트 단위로 같다`: 테스트 파일 안에 현재 `pieceSdfBaker`의 표본 루프를 그대로 옮긴 `bakeReference`를 두고, 모든 오목 젤리 종류(`getJellyShape`의 오목 외곽)와 무작위 직선·곡선 절단 조각(기존 "빠른 경로" 테스트의 조각 생성 재사용)에서 `data[i]`가 모두 `Object.is`로 같다. 행 묶음 굽기(`bakeRows(0,16)…`)도 같다. - [ ] **Step 2: 실행해 실패 확인** — `npx vitest run tests/Polygon.test.ts tests/PieceSdf.test.ts` → `rowCrossings` 없음으로 FAIL. - [ ] **Step 3: 구현** — `rowCrossings` 추가. `pieceSdfBaker`의 `bakeRows`는 `original`이 있을 때 행마다 `original`과 `polygon`의 교차표를 한 번 만들고(행 밖 재사용 배열), 표본 x를 왼쪽부터 훑으며 각 표에서 `x < xi`인 개수의 짝홀을 포인터로 센다. 볼록 원본 경로와 거리 계산은 바꾸지 않는다. - [ ] **Step 4: 통과 확인** — 같은 명령 PASS. - [ ] **Step 5: 분할 판단** — 모바일 성능 프로젝트에서 `concave-cut`·`stamp`의 `prepMaxMs`를 보고, SDF 단계 하나가 CPU 4배 감속에서 4ms를 넘으면 `PieceSplitter.prebuild`·`Game.prepareTable`·`StampMode` 미리 굽기를 `pieceSdfBaker` 16행 묶음 + `clock.nextFrame()`으로 나눈다(`SwipePrep`과 같은 방식). 넘지 않으면 나누지 않고 측정값을 기록에 적는다. - [ ] **Step 6: 커밋** — `npm test && npm run build`, 커밋 `조각 SDF 오목 원본 포함 판정을 행 단위 교차표로 계산`. ### Task 3: 메시 코어 기억 **Files:** - Modify: `src/geometry/JellyMeshBuilder.ts` - Test: `tests/JellyMesh.test.ts` **Interfaces:** - Produces: `buildMeshCore` 시그니처 그대로. 같은 배열·같은 `(bevel, thickness, arcStep)`이면 같은 `MeshCore` 객체(또는 `null`)를 돌려준다. 키는 `WeakMap>`, 안쪽 키 `` `${bevel}|${thickness}|${arcStep}` ``. 돌려준 `MeshCore`와 `core` 배열은 호출자가 바꾸지 않는다(`buildJellyMeshSides` 등 사용처를 확인하고, 바꾸는 곳이 있으면 그쪽에서 복사). - [ ] **Step 1: 실패하는 테스트** — `JellyMesh.test.ts`: - `buildMeshCore 는 같은 배열·옵션이면 같은 결과 객체를 돌려준다`: 오목 외곽으로 두 번 호출 → `toBe`. 옵션 `thickness`만 다르면 `not.toBe`. 새 배열(내용 같음)이면 `not.toBe`. - `기억된 코어로 만든 메시가 처음 만든 메시와 같다`: 같은 조각으로 `buildJellyMesh`를 두 번 → `pos`·`idx` 배열 `toEqual`. - `null 결과도 기억한다`: 가는 목 다각형(기존 null 테스트의 입력)으로 두 번 → 둘 다 `toBe(null)`. - [ ] **Step 2: 실패 확인** — `npx vitest run tests/JellyMesh.test.ts` → 첫 테스트 FAIL. - [ ] **Step 3: 구현** — 위 키 구조로 기억. 기존 메시 퍼즈(`tests/JellyMeshFuzz.*.test.ts`)가 같은 배열을 다른 옵션으로 부르는지 확인한다. - [ ] **Step 4: 통과 확인** — `npx vitest run tests/JellyMesh.test.ts tests/JellyMeshFuzz.fruitVegConcave.test.ts tests/JellyMeshFuzz.solidConcave.test.ts` PASS. - [ ] **Step 5: 커밋** — `npm test && npm run build`, 커밋 `메시 코어를 다각형 배열·옵션별로 기억해 절단 판정과 메시 생성의 중복 계산 제거`. ### Task 4: 결과 카드 준비에서 곡선 첫 가이드를 프레임 분할 **Files:** - Modify: `src/stages/LevelDefinitions.ts`, `src/core/Game.ts` (`prepareTable`) - Test: `tests/CurveGuides.test.ts` **Interfaces:** - Produces (`LevelDefinitions.ts`): - `export function stepGuideReady(level: LevelDefinition): boolean` — `cutSteps`가 없거나 첫 가이드 캐시가 있으면 참. - `export function* stepGuideWarm(level: LevelDefinition): Generator` — `guidePaths`의 첫 가이드 계산과 같은 생성기를 한 후보씩 `yield`하며 돌리고 끝나면 `initialStepGuides`에 넣는다. 이미 있으면 바로 끝난다. 같은 레벨에 대해 진행 중인 생성기 상태를 모듈 안에 하나 두고(`WeakMap`), `guidePaths`가 캐시 없이 불리면 그 진행 중 생성기를 끝까지 돌려 이어 쓴다(처음부터 다시 하지 않는다). - [ ] **Step 1: 실패하는 테스트** — `CurveGuides.test.ts`: - `stepGuideWarm 으로 채운 첫 가이드는 동기 계산과 같다`: 곡선 레벨 70~77 각각, 새 레벨 객체 사본 두 개 중 하나는 `guidePaths`, 하나는 `stepGuideWarm`을 끝까지 돌린 뒤 `guidePaths` → 점열 `toEqual`. - `중간까지 돌린 뒤 guidePaths 를 부르면 이어서 같은 결과를 낸다`: 생성기를 3번 `next()` 후 `guidePaths` → 위와 같은 점열, `stepGuideReady` 참. - `cutSteps 가 없으면 바로 끝나고 준비 상태다`. - [ ] **Step 2: 실패 확인** — `npx vitest run tests/CurveGuides.test.ts` FAIL. - [ ] **Step 3: 구현** — 위 두 함수. `Game.prepareTable`에서 `bakeOutlineSdfSteps` 루프 앞에 `for (const _ of stepGuideWarm(level)) { await this.clock.nextFrame(); if (this.stalePrepare(prep)) return; }`. 한 프레임에 후보 하나씩이 너무 느리면(75판 결과 카드 3.1초 안에 못 끝남) 프레임 예산 2ms 안에서 여러 후보를 돌린다. - [ ] **Step 4: 통과 확인** — 같은 테스트 PASS. - [ ] **Step 5: 커밋** — `npm test && npm run build`, 커밋 `결과 카드 준비에서 곡선 첫 가이드를 프레임 나눠 미리 계산`. ### Task 5: 홈 경로 가이드 생성 미루기 **Files:** - Modify: `src/stages/StageSession.ts`, `src/core/Game.ts` (`start`, `showMenu`, `loadLevel`, `enterPlay`, `frame`, 메뉴 경로 `onJump`·`onReplay`), `src/core/modes/KnifeMode.ts`, `src/core/GameDebug.ts`, `src/core/JellyDebug.ts`, `e2e/debug-api.spec.ts`, `docs/agent-reference/debug-api.md` - Test: `tests/StageSessionRules.test.ts`, `tests/KnifeMode.test.ts`, `e2e/curve-lifecycle.spec.ts` **Interfaces:** - Consumes: `stepGuideReady`, `stepGuideWarm`(Task 4). - Produces: - `StageSession`의 `_guides`는 처음 읽을 때 만든다(생성자는 `guidePaths`를 부르지 않는다). 공개 API(`guides`, `currentGuide` 등) 그대로. - `Game.loadLevel(id: number, opts?: { deferGuides?: boolean })` — `deferGuides`이고 `!stepGuideReady(level)`이면 `this.guides`를 만들지 않고 `this.guideWarm = stepGuideWarm(level)`을 둔다. `start`(홈으로 가는 경우)와 `showMenu`만 `deferGuides: true`로 부른다. - `Game.frame`: `phase === 'menu'`이고 `guideWarm`이 있으면 2ms 안에서 `next()`를 돌리고, 끝나면 `CuttingGuides`를 만들어 `plate.content`에 숨긴 채 붙인다. - `Game.enterPlay`: `guideWarm`이 남았으면 끝까지 돌리고 가이드를 만든 뒤 진행한다(기존 `introGuides`·`showPlayUi` 순서 유지). - 다른 `loadLevel` 호출과 `loadLevel` 시작 시 이전 `guideWarm`은 버린다(`null`). - `KnifeMode.tutorialPath`는 튜토리얼을 처음 그릴 때 `guidePaths(level)`로 만든다. - 디버그 `guideWarmPending(): boolean`. - [ ] **Step 1: 실패하는 테스트** - `StageSessionRules.test.ts` `곡선 레벨 세션 생성은 첫 가이드를 계산하지 않는다`: 새 레벨 사본으로 `new StageSession(level)` 뒤 `stepGuideReady(level) === false`, `session.guides` 접근 뒤 참이고 점열이 `guidePaths(level)`과 같다. - `KnifeMode.test.ts` `튜토리얼 경로는 진입 때 가이드를 계산하지 않는다`: 곡선 튜토리얼 레벨 사본으로 `enter` 뒤 `stepGuideReady` 거짓, 첫 프레임 갱신 뒤 경로가 `guidePaths(level)[0].points`와 같다. (곡선 판에 튜토리얼이 없으면 이 테스트는 직선 레벨에서 경로가 기존과 같은지만 확인한다.) - `curve-lifecycle.spec.ts` 데스크톱 기능 프로젝트: - `홈 판이 곡선 판이면 홈에 있는 동안 가이드를 계산하고 PLAY 뒤 가이드가 같다`: 진행을 75판으로 주입 → 새로 불러옴 → 홈에서 `guideWarmPending()`이 참에서 거짓으로 바뀜 → PLAY → `guideScreenPoints()`가 `?level=75` 직접 진입의 값과 같다. - `즉시 PLAY 도 같은 가이드다`: 홈 표시 직후 PLAY → 같은 비교. - `미룬 계산 중 다른 판으로 가면 섞이지 않는다`: 홈 표시 직후 레벨 선택으로 74판 진입 → `guideWarmPending()` 거짓, 가이드가 74판 직접 진입과 같다. - [ ] **Step 2: 실패 확인** — 단위 두 파일 FAIL(E2E는 Step 4에서 함께). - [ ] **Step 3: 구현** — 위 인터페이스대로. 메뉴에서 가이드가 없는 동안 `this.guides?.` 사용처(표시 토글, `introGuides`, 디버그)는 그대로 null 안전하다. `trackLevelShaders`·`keepLevelShaders`는 세션 가이드를 읽지 않는지 확인한다. - [ ] **Step 4: 통과 확인** — `npx vitest run tests/StageSessionRules.test.ts tests/KnifeMode.test.ts tests/CurveGuides.test.ts`, `npm run build`, `npx playwright test e2e/curve-lifecycle.spec.ts e2e/debug-api.spec.ts --project=desktop-chromium` PASS. - [ ] **Step 5: 커밋** — `npm test && npm run build`, 커밋 `홈용 판 불러오기에서 곡선 첫 가이드를 홈 화면 프레임으로 미룸`. ### Task 6: 압착 장비 모델을 Game이 소유해 판 사이 재사용 **Files:** - Modify: `src/core/modes/GameMode.ts`(`ModeHost`), `src/core/Game.ts`(호스트 구현, `applyEquipment`, 컨텍스트 손실·복구, `dispose`), `src/core/crush/CrushScene.ts`, `src/core/odd/OddJellyScene.ts`, `src/core/modes/BearCrushMode.ts`, `src/core/modes/OddJellyMode.ts` - Test: `tests/CrushModels.test.ts`, `tests/OddJellyMode.test.ts`, 신규 `tests/PressModelPool.test.ts` - Create: `src/core/crush/PressModelPool.ts` **Interfaces:** - Produces (`PressModelPool.ts`, three 사용 가능): - `export class PressModelPool { constructor(create: (id: string) => EquipmentModel); borrow(id: string): { model: EquipmentModel; created: boolean }; prepare(id: string): void; release(): void; dispose(): void; readonly current: EquipmentModel | null }` - `borrow(id)`: 같은 id 모델이 있으면 부모에서 떼고 처음 자세(만들 때 저장한 `object`의 position·quaternion·scale)로 되돌리고 `press?.(0)`을 불러 `created: false`로 돌려준다. 없거나 id가 다르면 옛 모델을 `dispose()`하고 새로 만들어 `created: true`로 돌려준다(`prepare`로 만들어 둔 모델의 첫 `borrow`도 아직 미리 컴파일 전이므로 `created: true`). - `prepare(id)`: 모델이 없거나 id가 다르면 만들어 둔다(결과 카드용). - `release()`: 빌린 쪽이 `exit`에서 부른다. 부모에서 떼기만 한다. - `dispose()`: 모델 해제. `applyEquipment`(압착 장착 변경), 컨텍스트 손실, `Game.dispose`에서 부른다. - `ModeHost.createPressModel()`을 `ModeHost.pressModels: Pick`와 `ModeHost.pressEquipment(): string`(장착 압착 장비 id)로 바꾼다. 디버그 `equipment()`의 압착 모델 id 표시(`pressModelId`)는 유지한다. - `CrushScene`·`OddJellyScene`의 `dispose`는 `pressModel.dispose()` 대신 `pressModel.object.removeFromParent()`. - 미리 컴파일(`precompilePress`, `OddJellyScene.precompile`)은 `borrow`의 `created`가 참일 때만 한다. - [ ] **Step 1: 실패하는 테스트** — `PressModelPool.test.ts`: - `같은 장비면 같은 모델을 돌려주고 created 는 처음만 참`. - `prepare 로 만든 모델의 첫 borrow 는 created 가 참이고 새로 만들지 않는다`(팩토리 호출 1회). - `재사용 모델은 처음 자세로 돌아온다`: 빌림 → `object.position.y = 3`, `press(1)` 호출 흔적 → `release` → 다시 `borrow` → position·quaternion·scale이 처음과 같고 `press`가 `0`으로 불렸다(모의 모델의 `press` 기록). - `장비가 바뀌면 옛 모델을 해제한다`: `borrow('piston-press')` → `borrow('cat-paw')` → 첫 모델 `dispose` 1회. - `dispose 뒤 borrow 는 새로 만든다`(컨텍스트 손실 경로). - `OddJellyMode.test.ts` 기존 `setup`의 `createPressModel`을 `pressModels`·`pressEquipment` 모의로 바꾸고, `exit 뒤 다시 enter 해도 압착 모델을 해제하지 않는다`를 추가한다. - [ ] **Step 2: 실패 확인** — `npx vitest run tests/PressModelPool.test.ts tests/OddJellyMode.test.ts` FAIL. - [ ] **Step 3: 구현** — 위 인터페이스대로. 두 모드의 `ModePrep.prepare`에서 첫 줄로 `await host.clock.nextFrame()` 뒤 `host.pressModels.prepare(host.pressEquipment())`를 부른다(결과 카드 표시 프레임과 겹치지 않게). 젤리 준비는 Task 7에서 붙인다. - [ ] **Step 4: 통과 확인** — `npx vitest run tests/PressModelPool.test.ts tests/OddJellyMode.test.ts tests/CrushModels.test.ts tests/EquipmentModels.test.ts` PASS. - [ ] **Step 5: 커밋** — `npm test && npm run build`, 커밋 `압착 장비 모델을 Game 이 소유해 판 사이에 재사용`. ### Task 7: 곰 압착·찾기 젤리 준비를 결과 카드로 당기고 진입에서 이어 하기 **Files:** - Create: `src/core/crush/PressJellyPrep.ts` - Modify: `src/core/modes/BearCrushMode.ts`, `src/core/modes/OddJellyMode.ts` - Test: `tests/OddJellyMode.test.ts`, 신규 `tests/BearCrushMode.test.ts`(OddJellyMode 테스트의 `setup` 방식으로 호스트 모의) **Interfaces:** - Consumes: `ModeHost.prebuildParts`, `ModeHost.clock`, `ModeHost.tier()`, `PressModelPool`(Task 6), `buildMeshCore` 기억(Task 3). - Produces (`PressJellyPrep.ts`): - `export class PressJellyPrep { constructor(levelId: number, run: (stale: () => boolean) => Promise, dispose: (v: T) => void); readonly levelId: number; readonly done: Promise; cancel(): void; adopt(stale: () => boolean): Promise }` - 생성 즉시 `run`을 시작한다. `cancel()`은 진행을 멈추고 만든 결과를 `dispose`한다(이미 끝났으면 결과 해제). `adopt(stale)`은 준비의 취소 조건을 진입한 판의 `stale`로 바꾸고(이후 `cancel`이 아닌 그 `stale`로 멈춤) 결과를 넘긴다. 넘긴 결과의 해제 책임은 받은 쪽이다. - 두 모드: - `prep.prepare(level)`: `PressJellyPrep`을 만든다. `run`은 지금 `prepare(level, generation)`의 준비 부분(외곽 SDF·메시·재질·등급 맞춤 컴파일)을 `host.pieces`·`content`에 붙이지 않고 `JellyPiece[]`(+찾기: 외곽 SDF 맵)로 돌려준다. - `prep.drop()` → `cancel()`, `prep.id()` → `levelId`. - `enter(level)`: 준비가 있고 `levelId === level.id`면 `adopt(stale)`로 받아 붙이고 `ready`, 아니면 버리고 지금처럼 진입 후 준비를 한다. 받은 결과의 등급이 현재와 다르면 기존 루프처럼 현재 등급 재질로 바꿔 데운다. - 찾기 준비 안에서는 `oddJellyShape(variant, strength)`를 `variant:strength` 키로 한 번만 만든다. - [ ] **Step 1: 실패하는 테스트** - `OddJellyMode.test.ts` / `BearCrushMode.test.ts` 각각: - `결과 카드 준비를 넘겨받으면 진입 프레임에 ready 다`: `prep.prepare(level)` → 큐 비움 → `enter(level)` → 마이크로태스크 비움 → `ready` 참, `prebuildParts` 호출 수가 준비 때 한 번분만. - `준비가 끝나기 전에 진입하면 이어서 끝낸다`: `prepare` 뒤 큐를 두 번만 비우고 `enter` → 큐 비움 → `ready` 참, `prebuildParts` 총 호출이 몸 수만큼(처음부터 다시 하지 않음). - `이어서 하던 중 exit 하면 만든 자원을 모두 해제한다`: 위 상황에서 `ready` 전에 `exit` → 만든 `JellyPiece`·자산 `dispose` 호출, `host.pieces` 비어 있음. - `다른 판으로 진입하면 준비를 버린다`: `prepare(66)` → `enter(67)` → 66 준비 자원 해제, 67은 진입 후 준비. - `준비 중 등급이 바뀌면 현재 등급 재질로 ready`: 모의 `tier()`를 준비 도중 바꿈 → 붙은 조각 재질의 등급이 새 값. - `drop 은 끝난 준비의 자원도 해제한다`. - [ ] **Step 2: 실패 확인** — `npx vitest run tests/OddJellyMode.test.ts tests/BearCrushMode.test.ts` FAIL. - [ ] **Step 3: 구현** — 위 인터페이스대로. `Game.prepareLevel`의 `replacesTable` 분기가 `prepare(level, () => false)`로 부르는 계약은 그대로다(취소는 `drop`). 컨텍스트 복구가 `id()`로 같은 판을 다시 준비하는 경로를 확인한다. - [ ] **Step 4: 통과 확인** — 단위 PASS 후 `npm run build`, `npx playwright test e2e/bear-crush.spec.ts e2e/odd-jelly.spec.ts e2e/equipment-crush.spec.ts e2e/context-loss.spec.ts e2e/shader-compile.spec.ts --project=mobile-chromium` PASS. - [ ] **Step 5: 커밋** — `npm test && npm run build`, 커밋 `곰 압착·찾기 젤리 준비를 결과 카드로 당기고 진입에서 이어 받기`. ### Task 8: 마감 검증·실기기 A/B·문서·머지 **Files:** - Modify: `PROGRESS.md`, `docs/agent-reference/rendering.md`(SDF·코어 기억), `docs/agent-reference/modes.md`(압착·찾기 준비 인계, 모델 재사용), `docs/agent-reference/architecture.md`(`ModeHost` 압착 모델 빌림), 이 계획의 "측정 기록", 설계 문서 상태 줄 - [ ] **Step 1: 전체 검증** — `npm test`, `npm run build`, `npm run e2e:mobile`(0 실패). 결과 수치를 기록한다. - [ ] **Step 2: 에뮬레이션 성능 A/B** — `docs/agent-reference/testing.md`의 성능 A/B 절차로 Task 1 커밋 대 마지막 커밋을 데스크톱 → 모바일 순서로 잰다. `refactor-baseline.md` 6절 규칙(중앙값 max(1ms, 20%))으로 회귀가 없어야 한다. 회귀가 있으면 원인을 고치고 다시 잰다. - [ ] **Step 3: 실기기 A/B** — `KEYCODE_WAKEUP`·`svc power stayon usb` 후 `scripts/device-ab.sh HEAD 3 "절단 준비 순간"`. 끝나면 `svc power stayon false`. `summary.md`를 "측정 기록 → A/B"에 붙이고 설계 6.2의 장면별 조건을 하나씩 판정한다. 조건을 못 지킨 장면이 있으면 머지 전에 사용자에게 알린다. - [ ] **Step 4: 문서** — `PROGRESS.md`에 "성능 하위 프로젝트 B(2026-10-29, main 반영·미배포)" 절을 A 절 형식으로 쓰고(실기기 장면별 표, 에뮬레이션 결과, 남은 것: iPhone 미측정), "다음 할 일"에서 B를 지운다. 상세 문서 세 개를 갱신한다. - [ ] **Step 5: 최종 독립 리뷰** — Opus / medium으로 브랜치 전체 리뷰. 지적을 반영하고 필요한 단위 테스트만 다시 돌린다. - [ ] **Step 6: 머지** — main으로 `--no-ff` 머지 커밋 `성능 하위 프로젝트 B: 절단 준비 CPU·판 전환 끊김 줄이기 머지`. **푸시하지 않는다.** --- ## 측정 기록 ### 기준선 (Task 1) `scripts/device-ab.sh 637a3d2 637a3d2 3 "절단 준비 순간"`(같은 APK를 base·head로 번갈아 3회씩, 즉 표본 6개). S22 Ultra, medium, 회차마다 강제 종료. 결과 폴더 `.playwright-mcp/device-ab/20261029-110807`. 기준선은 base·head 두 줄을 함께 본다(차이는 측정 흔들림 폭). | 순간 | APK | n | maxGapMs | blocking | loadMs | readyMs | prepMs | |---|---|---|---|---|---|---|---| | concave-cut:cut | base | 3 | 25.9 | - | - | - | 8.2 | | concave-cut:cut | head | 3 | 30.1 | - | - | - | 6.0 | | crush-transition-heavy:card | base | 3 | 16.7 | - | 205.0 | 579.7 | - | | crush-transition-heavy:card | head | 3 | 13.8 | - | 213.6 | 496.5 | - | | crush-transition-heavy:entry | base | 3 | 227.4 | - | 205.0 | 579.7 | - | | crush-transition-heavy:entry | head | 3 | 222.7 | - | 213.6 | 496.5 | - | | crush-transition-heavy:total | base | 3 | 227.4 | - | 205.0 | 579.7 | - | | crush-transition-heavy:total | head | 3 | 222.7 | - | 213.6 | 496.5 | - | | crush-transition-quick:card | base | 3 | 14.1 | - | 23.7 | 333.4 | - | | crush-transition-quick:card | head | 3 | 17.3 | - | 20.6 | 328.7 | - | | crush-transition-quick:entry | base | 3 | 43.3 | - | 23.7 | 333.4 | - | | crush-transition-quick:entry | head | 3 | 48.9 | - | 20.6 | 328.7 | - | | crush-transition-quick:total | base | 3 | 43.3 | - | 23.7 | 333.4 | - | | crush-transition-quick:total | head | 3 | 48.9 | - | 20.6 | 328.7 | - | | crush-transition:card | base | 3 | 15.9 | - | 20.6 | 331.8 | - | | crush-transition:card | head | 3 | 17.2 | - | 24.8 | 321.7 | - | | crush-transition:entry | base | 3 | 44.7 | - | 20.6 | 331.8 | - | | crush-transition:entry | head | 3 | 43.7 | - | 24.8 | 321.7 | - | | crush-transition:total | base | 3 | 44.7 | - | 20.6 | 331.8 | - | | crush-transition:total | head | 3 | 43.7 | - | 24.8 | 321.7 | - | | curve-transition-quick:card | base | 3 | 38.2 | - | 4.0 | - | - | | curve-transition-quick:card | head | 3 | 39.2 | - | 3.7 | - | - | | curve-transition-quick:entry | base | 3 | 14.6 | - | 4.0 | - | - | | curve-transition-quick:entry | head | 3 | 12.9 | - | 3.7 | - | - | | curve-transition-quick:total | base | 3 | 38.2 | - | 4.0 | - | - | | curve-transition-quick:total | head | 3 | 39.2 | - | 3.7 | - | - | | curve-transition:card | base | 3 | 29.0 | - | 4.3 | - | - | | curve-transition:card | head | 3 | 36.9 | - | 3.7 | - | - | | curve-transition:entry | base | 3 | 14.1 | - | 4.3 | - | - | | curve-transition:entry | head | 3 | 13.7 | - | 3.7 | - | - | | curve-transition:total | base | 3 | 29.0 | - | 4.3 | - | - | | curve-transition:total | head | 3 | 36.9 | - | 3.7 | - | - | | home-curve-quick:home | base | 3 | 8.9 | - | 71.7 | - | - | | home-curve-quick:home | head | 3 | 10.0 | - | 72.2 | - | - | | home-curve-quick:play | base | 3 | 30.7 | - | 71.7 | - | - | | home-curve-quick:play | head | 3 | 29.9 | - | 72.2 | - | - | | home-curve:home | base | 3 | 16.2 | - | 72.4 | - | - | | home-curve:home | head | 3 | 16.6 | - | 78.3 | - | - | | home-curve:play | base | 3 | 49.4 | - | 72.4 | - | - | | home-curve:play | head | 3 | 80.0 | - | 78.3 | - | - | | home-return:menu-return | base | 3 | 114.8 | - | 60.4 | - | - | | home-return:menu-return | head | 3 | 106.9 | - | 52.5 | - | - | | odd-transition-quick:card | base | 3 | 18.6 | - | 27.1 | 1344.2 | - | | odd-transition-quick:card | head | 3 | 14.1 | - | 25.1 | 1314.0 | - | | odd-transition-quick:entry | base | 3 | 76.8 | - | 27.1 | 1344.2 | - | | odd-transition-quick:entry | head | 3 | 73.6 | - | 25.1 | 1314.0 | - | | odd-transition-quick:total | base | 3 | 76.8 | - | 27.1 | 1344.2 | - | | odd-transition-quick:total | head | 3 | 73.6 | - | 25.1 | 1314.0 | - | | odd-transition:card | base | 3 | 14.0 | - | 28.6 | 1319.4 | - | | odd-transition:card | head | 3 | 15.8 | - | 30.7 | 1337.3 | - | | odd-transition:entry | base | 3 | 75.7 | - | 28.6 | 1319.4 | - | | odd-transition:entry | head | 3 | 78.1 | - | 30.7 | 1337.3 | - | | odd-transition:total | base | 3 | 75.7 | - | 28.6 | 1319.4 | - | | odd-transition:total | head | 3 | 78.1 | - | 30.7 | 1337.3 | - | | stamp:cut | base | 3 | 51.6 | - | - | - | 16.9 | | stamp:cut | head | 3 | 58.9 | - | - | - | 19.1 | 장면별로 가장 큰 끊김의 출처(`loadMs`·`prepMs`·데스크톱 장비별 진입 시간·코드 경로로 판단): - `crush-transition-heavy`: 진입 225ms 중 `loadMs` 205ms가 고양이 발 압착 모델 생성(`createPressModel`, 데스크톱 92ms)이다 → Task 6 모델 재사용·결과 카드 준비. - `crush-transition`: 진입 44ms. `loadMs` 21~25ms(기본 피스톤 모델 + 장면) 뒤 진입 후 곰 메시 준비 프레임 → Task 3 코어 기억, Task 6·7. - `odd-transition`: 진입 74~78ms, `readyMs` 1.3초. 진입 뒤 곰 외곽·변형 외곽 SDF·메시·컴파일 준비 프레임과 `loadMs` 25~31ms(모델·장면) → Task 6·7. - `curve-transition`: 결과 카드 29~39ms가 `prepareTable` 안 `new StageSession`의 첫 가이드 동기 계산이다(진입 13~14ms는 이미 작다) → Task 4. - `home-return`: 107~115ms. `loadMs` 53~60ms(첫 가이드 동기 계산 + 테이블 젤리 동기 생성)와 메인 장면 전환 → Task 5(가이드만 대상). - `home-curve`: 시작 `loadMs` 72~78ms(첫 가이드 + 테이블 젤리, 첫 그리기 전). `play` 49~80ms는 PLAY 때 장면·시점 전환과 첫 플레이 그리기 → Task 5는 시작 `loadMs`를 줄인다. `play` 구간은 기준보다 나빠지지 않는지 본다. - `stamp`: 52~59ms, `prepMs` 17~19ms(찍기 조각 메시·SDF 준비 단계) → Task 2·3. - `concave-cut`: 26~30ms, `prepMs` 6~8ms → Task 2·3. ### A/B (Task 8) `scripts/device-ab.sh 637a3d2 aaa0a0e 3 "절단 준비 순간"`. S22 Ultra, medium, 회차마다 강제 종료, base·head 번갈아 3회. 결과 폴더 `.playwright-mcp/device-ab/20261029-113247`. base 3회차 `home-curve`가 홈 대기 시간 초과로 1회 빠졌다(base n=2). 리뷰 반영 커밋 `e4ccde9`(마시멜로 연출 상태 초기화)는 측정 뒤라 대상이 아니다(성능 무관). | 순간 | APK | n | maxGapMs | blocking | loadMs | readyMs | prepMs | |---|---|---|---|---|---|---|---| | concave-cut:cut | base | 3 | 35.2 | - | - | - | 7.8 | | concave-cut:cut | head | 3 | 27.4 | - | - | - | 5.3 | | crush-transition-heavy:card | base | 3 | 14.1 | - | 235.3 | 533.2 | - | | crush-transition-heavy:card | head | 3 | 234.7 | - | 13.1 | 91.2 | - | | crush-transition-heavy:entry | base | 3 | 245.0 | - | 235.3 | 533.2 | - | | crush-transition-heavy:entry | head | 3 | 91.2 | - | 13.1 | 91.2 | - | | crush-transition-heavy:total | base | 3 | 245.0 | - | 235.3 | 533.2 | - | | crush-transition-heavy:total | head | 3 | 234.7 | - | 13.1 | 91.2 | - | | crush-transition-quick:card | base | 3 | 15.2 | - | 21.7 | 326.1 | - | | crush-transition-quick:card | head | 3 | 14.9 | - | 7.1 | 6.8 | - | | crush-transition-quick:entry | base | 3 | 41.6 | - | 21.7 | 326.1 | - | | crush-transition-quick:entry | head | 3 | 17.0 | - | 7.1 | 6.8 | - | | crush-transition-quick:total | base | 3 | 41.6 | - | 21.7 | 326.1 | - | | crush-transition-quick:total | head | 3 | 17.0 | - | 7.1 | 6.8 | - | | crush-transition:card | base | 3 | 16.8 | - | 26.6 | 327.9 | - | | crush-transition:card | head | 3 | 15.0 | - | 6.7 | 6.9 | - | | crush-transition:entry | base | 3 | 44.3 | - | 26.6 | 327.9 | - | | crush-transition:entry | head | 3 | 19.6 | - | 6.7 | 6.9 | - | | crush-transition:total | base | 3 | 44.3 | - | 26.6 | 327.9 | - | | crush-transition:total | head | 3 | 19.6 | - | 6.7 | 6.9 | - | | curve-transition-quick:card | base | 3 | 35.6 | - | 3.6 | - | - | | curve-transition-quick:card | head | 3 | 42.0 | - | 3.8 | - | - | | curve-transition-quick:entry | base | 3 | 13.7 | - | 3.6 | - | - | | curve-transition-quick:entry | head | 3 | 14.0 | - | 3.8 | - | - | | curve-transition-quick:total | base | 3 | 35.6 | - | 3.6 | - | - | | curve-transition-quick:total | head | 3 | 42.0 | - | 3.8 | - | - | | curve-transition:card | base | 3 | 32.0 | - | 3.6 | - | - | | curve-transition:card | head | 3 | 29.8 | - | 4.1 | - | - | | curve-transition:entry | base | 3 | 14.2 | - | 3.6 | - | - | | curve-transition:entry | head | 3 | 15.9 | - | 4.1 | - | - | | curve-transition:total | base | 3 | 32.0 | - | 3.6 | - | - | | curve-transition:total | head | 3 | 29.8 | - | 4.1 | - | - | | home-curve-quick:home | base | 3 | 8.8 | - | 81.7 | - | - | | home-curve-quick:home | head | 3 | 3.2 | - | 60.1 | - | - | | home-curve-quick:play | base | 3 | 29.4 | - | 81.7 | - | - | | home-curve-quick:play | head | 3 | 29.7 | - | 60.1 | - | - | | home-curve:home | base | 2 | 14.5 | - | 81.6 | - | - | | home-curve:home | head | 3 | 15.0 | - | 53.4 | - | - | | home-curve:play | base | 2 | 30.3 | - | 81.6 | - | - | | home-curve:play | head | 3 | 34.4 | - | 53.4 | - | - | | home-return:menu-return | base | 3 | 105.8 | - | 60.6 | - | - | | home-return:menu-return | head | 3 | 99.1 | - | 50.2 | - | - | | odd-transition-quick:card | base | 3 | 14.3 | - | 23.5 | 1300.5 | - | | odd-transition-quick:card | head | 3 | 26.5 | - | 7.1 | 946.5 | - | | odd-transition-quick:entry | base | 3 | 76.7 | - | 23.5 | 1300.5 | - | | odd-transition-quick:entry | head | 3 | 72.1 | - | 7.1 | 946.5 | - | | odd-transition-quick:total | base | 3 | 76.7 | - | 23.5 | 1300.5 | - | | odd-transition-quick:total | head | 3 | 72.1 | - | 7.1 | 946.5 | - | | odd-transition:card | base | 3 | 17.3 | - | 26.9 | 1315.9 | - | | odd-transition:card | head | 3 | 31.1 | - | 12.3 | 18.7 | - | | odd-transition:entry | base | 3 | 79.9 | - | 26.9 | 1315.9 | - | | odd-transition:entry | head | 3 | 85.2 | - | 12.3 | 18.7 | - | | odd-transition:total | base | 3 | 79.9 | - | 26.9 | 1315.9 | - | | odd-transition:total | head | 3 | 85.2 | - | 12.3 | 18.7 | - | | stamp:cut | base | 3 | 51.6 | - | - | - | 16.9 | | stamp:cut | head | 3 | 54.0 | - | - | - | 18.6 | 설계 6.2 판정(중앙값, 흔들림 판단은 `refactor-baseline.md` 6절 max(1ms, 20%)): | 장면 | 조건 | 결과 | |---|---|---| | concave-cut | 구간 개선 | 충족: 35.2 → 27.4ms | | stamp | 구간 개선 | **미충족**: 51.6 → 54.0ms(흔들림 폭 안). 최대 간격은 SDF·코어가 아니다(`prepMs` 17~19ms 단계는 찍기 조각 메시 준비, 나머지는 찍기 연출 그리기) | | curve-transition | 진입 개선 + 전체 악화 없음 | 진입 **미충족**(14.2 → 15.9, 이미 작음), 전체 충족(32.0 → 29.8). quick 전체 35.6 → 42.0(+6.4, 흔들림 폭 7.1 안). 결과 카드 최대 간격은 첫 가이드 계산이 아니라 결과 카드 등장 연출 프레임이다 | | crush-transition(+quick) | 진입 개선 + 전체 악화 없음 | 충족: 진입 44.3 → 19.6ms, 전체 44.3 → 19.6ms, readyMs 328 → 7ms | | crush-transition-heavy(고양이 발) | 같음 | 진입 245 → 91ms, 전체 245 → 235ms(악화 없음). **고양이 발 모델 생성 약 235ms가 결과 카드 프레임으로 옮겨 갔다**(처음 한 번만, 이후 판은 재사용). 진입 91ms는 새 모델 셰이더 미리 컴파일·첫 그리기 | | odd-transition | 같음 | readyMs 1316 → 19ms. 진입 **미충족**(79.9 → 85.2, 흔들림 폭 안). 최대 간격은 준비가 아니라 ready 직후 곰 32마리 첫 그리기(지오메트리·텍스처 GPU 업로드)로 보인다 | | odd-transition-quick | 같음 | 충족(76.7 → 72.1), readyMs 1300 → 947ms(결과 카드에서 덜 끝나 이어서 함) | | home-curve(+quick) | 시작 loadMs 개선, 홈·PLAY 악화 없음 | 충족: loadMs 81.6 → 53.4ms(quick 81.7 → 60.1), 홈 14.5 → 15.0, PLAY 30.3 → 34.4(흔들림 폭 안) | | home-return | 개선 | 충족: 105.8 → 99.1ms(loadMs 60.6 → 50.2). 남은 시간은 테이블 젤리 동기 생성·메인 장면 전환(범위 밖) | 비압착 → 압착 첫 진입(첫 모델 생성) 경로는 실기기에서 재지 않았다(에뮬레이션·단위 테스트만). ### 찾기 진입 멈춤 조사와 추가 수정 뒤 A/B (Task 8 추가, 사용자 결정) 조사: 찾기 진입 뒤 85~99ms 멈춤은 곰 첫 그리기가 아니었다. 실기기 Chrome 트레이스와 WebGL 호출 기록에서, 진입 프레임에 셰이더 보관소가 찾기 '눌린 곰'(clearcoat 0) 젤리 변형을 새로 컴파일하고(곰 압착·찾기처럼 모드가 판 준비를 맡는 판은 결과 카드 동안 `keepLevelShaders`를 하지 않았다) 3프레임 뒤 보관 예열 그리기에서 three `onFirstUse`의 동기 질의(`getProgramInfoLog` 40~45ms: 링크 완료 대기, `getShaderInfoLog` 8~11·21~24ms: GPU 스레드 `ShCompile`)가 멈춤을 냈다. 곰을 8마리씩 미리 그리는 실험은 첫 진입에 효과가 없어 되돌렸다. 수정(`2d42dd1`): (1) `prepareLevel`이 `replacesTable` 모드에서도 결과 카드 다음 프레임부터 도착 판 셰이더를 쥔다. (2) three `checkShaderErrors`를 개발 빌드·자동화 브라우저에서만 켠다. `scripts/device-ab.sh 637a3d2 2d42dd1 3 "절단 준비 순간"`, 결과 폴더 `.playwright-mcp/device-ab/20261029-125338`, 실패 없음. | 순간 | APK | n | maxGapMs | blocking | loadMs | readyMs | prepMs | |---|---|---|---|---|---|---|---| | concave-cut:cut | base | 3 | 29.4 | - | - | - | 5.5 | | concave-cut:cut | head | 3 | 34.9 | - | - | - | 3.7 | | crush-transition-heavy:card | base | 3 | 18.5 | - | 209.4 | 520.3 | - | | crush-transition-heavy:card | head | 3 | 231.7 | - | 14.6 | 72.9 | - | | crush-transition-heavy:entry | base | 3 | 218.6 | - | 209.4 | 520.3 | - | | crush-transition-heavy:entry | head | 3 | 72.9 | - | 14.6 | 72.9 | - | | crush-transition-heavy:total | base | 3 | 218.6 | - | 209.4 | 520.3 | - | | crush-transition-heavy:total | head | 3 | 231.7 | - | 14.6 | 72.9 | - | | crush-transition-quick:card | base | 3 | 13.1 | - | 21.6 | 318.5 | - | | crush-transition-quick:card | head | 3 | 15.6 | - | 6.0 | 5.5 | - | | crush-transition-quick:entry | base | 3 | 43.3 | - | 21.6 | 318.5 | - | | crush-transition-quick:entry | head | 3 | 16.1 | - | 6.0 | 5.5 | - | | crush-transition-quick:total | base | 3 | 43.3 | - | 21.6 | 318.5 | - | | crush-transition-quick:total | head | 3 | 18.3 | - | 6.0 | 5.5 | - | | crush-transition:card | base | 3 | 16.1 | - | 23.3 | 327.1 | - | | crush-transition:card | head | 3 | 16.1 | - | 7.2 | 7.1 | - | | crush-transition:entry | base | 3 | 42.8 | - | 23.3 | 327.1 | - | | crush-transition:entry | head | 3 | 17.0 | - | 7.2 | 7.1 | - | | crush-transition:total | base | 3 | 42.8 | - | 23.3 | 327.1 | - | | crush-transition:total | head | 3 | 17.3 | - | 7.2 | 7.1 | - | | curve-transition-quick:card | base | 3 | 39.7 | - | 4.0 | - | - | | curve-transition-quick:card | head | 3 | 38.4 | - | 4.3 | - | - | | curve-transition-quick:entry | base | 3 | 14.4 | - | 4.0 | - | - | | curve-transition-quick:entry | head | 3 | 14.4 | - | 4.3 | - | - | | curve-transition-quick:total | base | 3 | 39.7 | - | 4.0 | - | - | | curve-transition-quick:total | head | 3 | 38.4 | - | 4.3 | - | - | | curve-transition:card | base | 3 | 35.0 | - | 3.5 | - | - | | curve-transition:card | head | 3 | 33.4 | - | 4.6 | - | - | | curve-transition:entry | base | 3 | 13.0 | - | 3.5 | - | - | | curve-transition:entry | head | 3 | 14.4 | - | 4.6 | - | - | | curve-transition:total | base | 3 | 35.0 | - | 3.5 | - | - | | curve-transition:total | head | 3 | 33.4 | - | 4.6 | - | - | | home-curve-quick:home | base | 3 | 9.2 | - | 79.2 | - | - | | home-curve-quick:home | head | 3 | 8.3 | - | 59.7 | - | - | | home-curve-quick:play | base | 3 | 29.2 | - | 79.2 | - | - | | home-curve-quick:play | head | 3 | 13.1 | - | 59.7 | - | - | | home-curve:home | base | 3 | 11.3 | - | 70.8 | - | - | | home-curve:home | head | 3 | 14.3 | - | 59.6 | - | - | | home-curve:play | base | 3 | 96.6 | - | 70.8 | - | - | | home-curve:play | head | 3 | 27.8 | - | 59.6 | - | - | | home-return:menu-return | base | 3 | 103.7 | - | 56.3 | - | - | | home-return:menu-return | head | 3 | 82.0 | - | 43.2 | - | - | | odd-transition-quick:card | base | 3 | 21.5 | - | 23.7 | 1339.4 | - | | odd-transition-quick:card | head | 3 | 28.0 | - | 8.6 | 864.1 | - | | odd-transition-quick:entry | base | 3 | 77.4 | - | 23.7 | 1339.4 | - | | odd-transition-quick:entry | head | 3 | 70.2 | - | 8.6 | 864.1 | - | | odd-transition-quick:total | base | 3 | 77.4 | - | 23.7 | 1339.4 | - | | odd-transition-quick:total | head | 3 | 70.2 | - | 8.6 | 864.1 | - | | odd-transition:card | base | 3 | 16.6 | - | 30.8 | 1334.7 | - | | odd-transition:card | head | 3 | 44.9 | - | 14.3 | 21.2 | - | | odd-transition:entry | base | 3 | 82.8 | - | 30.8 | 1334.7 | - | | odd-transition:entry | head | 3 | 52.8 | - | 14.3 | 21.2 | - | | odd-transition:total | base | 3 | 82.8 | - | 30.8 | 1334.7 | - | | odd-transition:total | head | 3 | 52.8 | - | 14.3 | 21.2 | - | | stamp:cut | base | 3 | 48.3 | - | - | - | 16.1 | | stamp:cut | head | 3 | 57.1 | - | - | - | 18.4 | 설계 6.2 최종 판정(이 A/B 기준): | 장면 | 결과 | |---|---| | concave-cut | **미충족**: 29.4 → 34.9ms(흔들림 폭 5.9 안, 첫 A/B는 35.2 → 27.4로 개선). `prepMs` 5.5 → 3.7 | | stamp | **미충족**: 48.3 → 57.1ms(흔들림 폭 9.7 안). `prepMs` 16.1 → 18.4(두 A/B 모두 약 +2ms, 판정선 3.2 안) | | curve-transition | 진입 **미충족**(13.0 → 14.4, 이미 작음), 전체 충족(35.0 → 33.4). quick 전체 39.7 → 38.4 | | crush-transition(+quick) | 충족: 진입 42.8 → 17.0, 전체 42.8 → 17.3, readyMs 327 → 7 | | crush-transition-heavy | 진입 218.6 → 72.9 충족, 전체 218.6 → 231.7(**+13, 흔들림 폭 안이나 악화 쪽**). 고양이 발 생성이 결과 카드 프레임으로 옮겨 감(처음 한 번) | | odd-transition | 충족: 진입 82.8 → 52.8, 전체 82.8 → 52.8, readyMs 1335 → 21. 결과 카드 16.6 → 44.9(셰이더 비용 일부가 결과 카드로) | | odd-transition-quick | 충족: 77.4 → 70.2(결과 카드에서 준비가 덜 끝나 진입에서 이어 함) | | home-curve(+quick) | 충족: loadMs 70.8 → 59.6(quick 79.2 → 59.7), PLAY 96.6 → 27.8(quick 29.2 → 13.1) | | home-return | 충족: 103.7 → 82.0 |