# 절단 준비 CPU·판 전환 끊김 줄이기 설계 (성능 하위 프로젝트 B) 작성일: 2026-10-29 상태: 사용자 승인(2026-10-29). 구현 계획: `docs/superpowers/plans/2026-10-29-cut-prep-cpu.md`. ## 1. 목적과 확정 요구 자르는 순간과 판이 바뀌는 순간에 순수 CPU 계산(SDF 굽기·메시 코어·가이드 생성·장비 모델 생성)이 한 프레임에 몰려 생기는 끊김을 줄인다. 성능 개선 3개 하위 프로젝트(A: 셰이더 재컴파일, B: 절단 준비 CPU·모드 전환, C: 지속 프레임 비용·투과 패스) 중 두 번째다. A는 main에 반영되었고 미배포다. 사용자가 확정한 내용: - 대상은 네 항목이다: (1) 조각 SDF 굽기, (2) 메시 코어 중복 계산, (3) 곡선 첫 가이드 생성, (4) 곰 압착·다른 젤리 찾기 판 전환. - **0단계에서 실기기 기준선을 먼저 잰다.** 측정 결과는 고칠 순서와 방법을 정하는 데 쓰며, 끊김이 작게 나온 항목도 개선 여지가 있으면 포함한다(항목을 빼지 않는다). - **실기기 전·후 측정을 완료 기준에 포함한다.** 기기·조건은 A와 같다: 연결된 Galaxy S22 Ultra(SM-S908N) 설치 앱(Capacitor Android WebView), medium 등급, 앱 강제 종료 후 처음 실행. 고치기 전·후 APK를 번갈아 3회 측정한 중앙값과 개선 폭을 기록한다. 절대 ms 예산은 두지 않는다. - 접근 1(항목별 계산 줄이기 + 필요한 곳만 프레임 분할)을 택했다(3절). - 압착 장비 모델은 장착이 바뀌지 않으면 **판 사이에 재사용**한다. 처음 한 번만 결과 카드 동안 만든다(4.4). - 전환 측정은 **결과 카드 구간과 진입 구간을 나눠** 기록한다. 판정은 진입 구간 개선 + 전환 전체 최대 프레임 간격이 기준보다 나빠지지 않음이다(6.2). - **홈 경로도 첫 가이드 대상에 포함한다.** 홈용으로 판을 불러올 때(앱 시작·홈 복귀) 가이드 생성을 미루고, 홈에 머무는 동안 프레임 나눠 계산하며, PLAY 때 남았으면 마저 계산한다(4.3). - 작업 브랜치에서 구현하고 **로컬 main 머지까지만** 한다. 푸시(=운영 배포)하지 않으며 A와 B의 배포는 사용자가 따로 결정한다. 설계자 판단(사용자 확인 대상, A의 원칙을 따름): - **외관과 판정 결과가 바뀌지 않아야 한다.** 계산 방법·시점만 바꾼다. SDF·메시·가이드 출력은 기존과 같아야 한다(5절의 동치 기준). - 기존 에뮬레이션 성능 예산(`perf.spec`)과 메시·퍼즈 품질 임계값은 완화하지 않는다. - iPhone 측정은 범위에서 빼고 미완료로 남긴다. ## 2. 현재 구조와 원인 | 항목 | 위치 | 현재 동작 | |---|---|---| | 조각 SDF | `src/geometry/PieceSdf.ts` `pieceSdfBaker` | 64×64 표본마다 모든 절단 변까지 거리를 잰다. 원본 외곽이 오목하면 표본마다 `pointInPolygon(original)`과 `pointInPolygon(polygon)`을 추가로 해 O(64²×(절단 변 + 원본 변 + 조각 변))이다. 휘둘러 베기(`SwipePrep`)만 행 묶음으로 나눠 굽고, 칼·천천히 썰기(`PieceSplitter.prebuild`)·틀 찍기(`StampMode`)·다음 판 준비(`Game.prepareTable`)·조각 생성 대체 경로(`JellyPiece` 생성자)는 `bakePieceSdf`로 한 프레임에 통째 굽는다 | | 메시 코어 | `src/cutting/CutResolver.ts:116`, `src/geometry/JellyMeshBuilder.ts:298` | 오목 조각은 절단 판정(`meshable`)에서 `buildMeshCore`를 한 번 부르고, 메시 생성(`buildJellyMeshSides`)에서 같은 다각형·같은 옵션으로 다시 부른다. 틀 찍기 후보 검사(`src/stages/Stamp.ts:64`)도 같은 함수를 쓴다. 곰 압착은 같은 외곽 배열로 여러 마리를 만들어 같은 계산이 마리 수만큼 반복된다. `Polygon.ts`의 `inscribedRadius`는 이미 다각형 배열을 키로 하는 `WeakMap`으로 결과를 기억한다 | | 곡선 첫 가이드 | `src/stages/LevelDefinitions.ts` `guidePaths` | `cutSteps` 레벨의 첫 가이드를 `createStepGuide` 생성기를 끝까지 동기로 돌려 만든다(후보 최대 4방향×3진폭×7이동, 후보마다 201점 부호 거리·`resolveCut`). 결과는 레벨별 `WeakMap`에 남는다. 첫 계산은 `StageSession` 생성자(`guidePaths` 호출)에서 일어난다. 결과 카드를 거치는 경로에서는 `Game.prepareTable`의 `new StageSession(level)`(결과 카드 중 한 프레임), 홈에서 바로 들어가는 경로에서는 `Game.loadLevel`(`loadMs` 구간)이다. `keepLevelShaders`·`KnifeMode` 튜토리얼도 같은 캐시를 읽는다 | | 곰 압착 판 전환 | `src/core/modes/BearCrushMode.ts` | `prep.replacesTable`이고 `prepare`가 비어 있어 결과 카드 동안 아무것도 미리 만들지 않는다. `enter`에서 장착 압착 장비 모델(`createEquipmentModel`, 홈·상점 측정 45~86ms)을 동기로 만든 뒤, 진입 후 프레임을 나눠 곰 여러 마리의 메시(같은 오목 외곽)를 만든다 | | 찾기 판 전환 | `src/core/modes/OddJellyMode.ts` | 같은 구조다. `enter`에서 압착 모델을 동기로 만들고, 진입 후 곰 외곽 SDF·변형별 외곽 SDF·젤리 메시를 프레임 나눠 만든 뒤 `ready`가 된다 | 기존 지표: `prepMaxMs`(준비 단계 하나의 최대 ms), `strokeMaxMs`, `loadMs`, `dropMaxGapMs`, A가 추가한 `perfMark`/`perfSince`(구간 최대 프레임 간격 `maxGapMs` 등). 곰 압착·찾기 판 전환은 현재 성능 E2E에 측정이 없다. ## 3. 접근 방식 - **접근 1(채택): 항목별 계산 줄이기 + 필요한 곳만 프레임 분할.** 계산량 자체를 줄이고, 그래도 한 프레임에 큰 덩어리가 남는 곳만 기존 준비 통로(`PieceSplitter.prebuild`, `Game.prepareTable`, `ModePrep`)에서 나눈다. 기존 자원 인계·취소·해제 규칙을 그대로 쓰고, 결과가 같은지 단위 테스트로 직접 비교할 수 있다. - 접근 2(기각): SDF·메시 생성을 Web Worker로 옮긴다. 메인 스레드 끊김은 거의 사라지지만 준비 자원의 인계·취소·해제 규칙을 스레드 경계에 맞춰 다시 짜야 하고, 전달 비용과 Capacitor·iOS WebView 검증 범위가 커진다. - 접근 3(기각): 계산은 두고 프레임 분할만 늘린다. 위험은 가장 작지만 총 준비 시간이 늘어 첫 절단·판 진입 대기가 길어질 수 있고, 메시 코어 중복은 해결되지 않는다. ## 4. 항목별 설계 ### 4.1 조각 SDF 굽기 - 오목 원본의 표본별 포함 판정 두 번을 **행 단위 판정**으로 바꾼다. 표본 행(y 고정)마다 원본·조각 다각형의 변과 그 수평선의 교차 x를 한 번 구해 정렬하고, 그 행의 표본 x를 차례로 훑어 안/밖을 정한다. 결과는 현재 `pointInPolygon`과 같아야 한다(경계 위 표본·꼭짓점을 지나는 행의 처리 규칙을 `pointInPolygon`과 맞춘다). - 가장 가까운 절단 변 탐색(표본 × 절단 변)은 이번에 바꾸지 않아도 된다. 0단계 측정에서 이 부분이 실기기 끊김의 주된 원인으로 나오면 결과가 같은 범위의 가지치기를 빌더가 추가할 수 있다. - 한 프레임에 통째로 굽는 경로(`PieceSplitter.prebuild`, `Game.prepareTable`, `StampMode`의 미리 굽기)는 0단계 측정상 한 단계가 예산을 넘거나 실기기 끊김에 보이면 `pieceSdfBaker`의 행 묶음으로 나눈다. 나누는 묶음 크기와 나눌 경로는 측정을 보고 빌더가 정한다. - 입력 처리 중 동기 굽기 금지(`inputMeshBuilds` 0)와 준비 자원 인계·취소·해제 책임은 그대로다. ### 4.2 메시 코어 기억 - `buildMeshCore` 결과를 `inscribedRadius`처럼 **다각형 배열을 키로 하는 `WeakMap`**에 기억한다. 옵션(`bevel`·`thickness`·`arcStep`)이 결과를 바꾸므로 키에 옵션을 함께 반영한다(배열별 작은 맵 등, 방식은 빌더). - 조각 다각형 배열은 만든 뒤 바꾸지 않는다는 기존 전제(`PieceData.polygon` 불변)에 기댄다. 기억된 `MeshCore`를 받는 쪽이 그 배열을 바꾸지 않는지 확인하고, 바꾸는 곳이 있으면 복사해 넘긴다. - `null` 결과(코어를 못 만듦)도 기억한다. - 효과가 나는 곳: 칼·찍기 절단 판정 → 메시 생성의 중복, 곰 압착 여러 마리(같은 외곽 배열), 찾기의 같은 변형·강도 곰. ### 4.3 곡선 첫 가이드 - 첫 가이드를 한 프레임에 통째로 계산하지 않게 한다. `createStepGuide` 생성기(이미 후보마다 `yield`한다)를 프레임마다 조금씩 돌려 레벨별 캐시를 미리 채우는 진입점을 둔다. - 결과 카드 경로: `Game.prepareTable`에서 `new StageSession(level)`을 만들기 **전에** 이 진입점으로 캐시를 채운다(취소는 `stalePrepare`를 따른다). 이후 `StageSession`·`keepLevelShaders`·`loadLevel`은 캐시를 읽기만 한다. - 홈 경로: PLAY는 판을 다시 불러오지 않는다. 홈 화면 뒤의 판은 앱 시작(`Game.start`, 첫 그리기 전)과 홈 복귀(`showMenu`, 홈 판이 지금 판과 다르거나 다시 불러와야 할 때)에서 `loadLevel`로 불린다. 이때 그 판이 `cutSteps` 판이고 캐시가 비어 있으면 **가이드 생성을 미룬다**: `StageSession`의 가이드, `Game.loadLevel`의 `CuttingGuides`, `KnifeMode`의 튜토리얼 경로가 가이드를 처음 필요로 할 때까지 만들지 않는다. 홈이 떠 있는 동안 프레임마다 생성기를 조금씩 돌리고, 끝나면 가이드 객체를 만든다. 홈 화면 연출 프레임을 나쁘게 하지 않는 묶음 크기로 한다. - PLAY(`enterPlay`) 때 아직 남았으면 남은 계산을 그 자리에서 동기로 마치고 가이드를 만든다. 그 판의 다른 입구(디버그 `gotoLevel`, `?level=`, 재개, Retry 등 홈을 거치지 않는 `loadLevel`)는 지금처럼 동기로 계산한다. - 결과 카드 경로에서 캐시가 다 채워지지 않은 채 진입하면(빠른 Next, 준비 취소) 남은 계산을 지금처럼 동기로 마친다(동작 유지). - 홈 판의 가이드가 아직 없는 동안 가이드를 읽는 곳(홈 화면 표시, 디버그 `guideScreenPoints` 등)은 빈 가이드로 동작하거나 계산을 마친다. 가이드 생성 시점만 바뀌고, 플레이가 시작된 뒤의 상태는 지금과 같아야 한다. 가이드 결과는 캐시 여부·분할 여부와 관계없이 같아야 한다(같은 생성기, 같은 입력, 같은 후보 순서). - `cutSteps`가 없는 판은 해당하지 않는다. ### 4.4 곰 압착·찾기 판 전환 - **압착 장비 모델을 판 사이에 재사용한다.** 지금은 `createPressModel`이 판마다 새 모델을 만들고 `CrushScene`·`OddJellyScene`의 `dispose`가 해제한다. 이를 Game(호스트) 쪽이 장착 장비 id별 모델 하나를 소유하는 구조로 바꾼다. - 장면은 모델을 빌려 쓰고 `exit` 때 떼기만 하며 해제하지 않는다. 모델의 연출 상태(눌림 자세·효과)는 다음 판에서 처음 상태로 되돌아가야 한다(리셋 방법은 빌더). - 장착이 바뀌면 이전 모델을 해제하고 다음 사용 때 새로 만든다. 컨텍스트 손실·복구 시 자원 처리는 기존 장비 모델(칼·메스 `EquippedTool`)과 같은 규칙을 따른다. - 아직 모델이 없으면 도착 판이 압착·찾기 모드인 결과 카드 준비에서 만든다. 결과 카드가 뜨는 프레임과 겹치지 않게 다음 프레임 이후로 미룬다. 홈에서 바로 들어가는 등 준비가 없으면 지금처럼 `enter`에서 만든다. - 셰이더는 A의 보관소 규칙대로 유지한다(모델 재사용으로 프로그램이 풀리지 않으므로 기존 `precompile`은 첫 생성 때만 필요하다). - **젤리 준비를 결과 카드 동안으로 당긴다.** 두 모드의 `ModePrep.prepare`가 지금 `enter` 뒤에 하는 준비(외곽 SDF·메시·재질·컴파일)를 결과 카드 동안 프레임을 나눠 하고, `enter`가 그 결과를 넘겨받아 바로 `ready`가 되게 한다. - 준비가 끝나기 전에 진입하면(Next·Retry·자동 진행) **하던 준비를 버리지 않고 진입 뒤 이어서 한다**(설계자 판단: 기존 테이블 준비가 일부만 된 준비를 넘겨받는 방식과 같다). 이어서 하는 준비는 진입한 판의 generation·`exit` 취소 규칙을 따른다. - 등급이 바뀌었으면 기존 루프처럼 현재 등급 재질로 바꿔 데운다. 준비가 없으면(홈 진입 등) 지금 경로(진입 후 준비)로 간다. `drop`·`id`·컨텍스트 복구 규약은 `ModePrep` 설명을 따른다. - 같은 외곽 곰 여러 마리는 4.2의 코어 기억으로 반복 계산이 준다. 찾기 모드의 변형 곰은 `oddJellyShape`가 부를 때마다 새 외곽 배열을 만들어 배열 키 기억이 듣지 않는다. 그래서 준비 안에서 변형·강도 키로 모양을 한 번만 만들어 재사용한다(설계자 판단). 같은 외곽 곰의 메시 공유 여부는 빌더가 판단하되, 조각별 자원 해제 책임이 꼬이면 공유하지 않는다. ## 5. 동치·정확성 기준 - SDF: 새 굽기와 기존 굽기(테스트 안에 기준 구현으로 보존)를 오목·볼록 원본, 다양한 절단 조합, 행 경계에 꼭짓점이 놓이는 경우를 포함한 무작위 조각에서 비교해 **모든 표본이 같아야 한다**(같은 부호, 거리 값 동일). 부동소수 순서 차이로 완전 동일이 불가능한 곳이 생기면 멈추고 허용 오차를 사용자에게 묻는다. - 메시 코어: 기억 전·후 메시 결과가 같다. 기존 메시 퍼즈 검사(`tests/meshFuzz.ts`)를 통과한다. - 곡선 가이드: 미리 계산·동기 계산의 가이드 점열이 같다. - 판 전환: 넘겨받은 준비와 진입 후 준비의 젤리 메시·재질 옵션·배치가 같다. ## 6. 측정 설계 ### 6.1 0단계(기준선) - 실기기 측정 장면을 `e2e/device/`와 `e2e/helpers/`에 추가한다. 기존 `scripts/device-ab.sh`로 돌린다. - 장면(대표 판은 빌더가 고르고 계획에 적는다): 1. 오목 원본 칼 판의 첫 절단 전후(손을 뗀 뒤 교체까지 구간의 `maxGapMs`, `prepMaxMs`) 2. 틀 찍기 판의 찍기 전후(같은 지표) 3. 곡선 단계(`cutSteps`) 판으로의 전환: 직전 판 클리어 → 결과 카드 → 다음 → 진입·착지 4. 곡선 단계 판이 홈 판인 홈 경로: (a) 앱 처음 실행의 시작 `loadLevel` 시간(`loadMs`)과 홈 표시 뒤 고정 시간 동안의 `maxGapMs`, (b) 플레이 중 Home으로 홈 복귀(홈 판이 다른 판일 때)한 프레임의 `maxGapMs`, (c) PLAY 진입 구간 `maxGapMs`. 홈에 머무는 시간은 장면에서 고정하고, 즉시 PLAY도 한 번 잰다 5. 곰 압착 판으로의 전환(3과 같은 경로, 추가로 진입부터 `ready`까지 시간). 장비는 기본 압착 장비와 가장 무거운 압착 장비 두 경우 6. 찾기 판으로의 전환(5와 같은 지표) - 전환 장면(3·5·6)은 **결과 카드 구간**(결과 카드 표시 ~ Next 입력)과 **진입 구간**(Next 입력 ~ 착지·`ready` 뒤 일정 시간)을 나눠 각 구간의 `maxGapMs`를 기록하고, `loadMs`도 남긴다. 결과 카드 체류는 실제 사용과 같게 자동 진행 대기로 하되, 즉시 Next를 누르는 경우도 한 번 함께 잰다(준비를 이어서 하는 경로 확인용). - 메시 코어 중복은 1·2·4의 지표 안에서 드러난다. 따로 장면을 두지 않아도 된다. - 에뮬레이션 쪽: 데스크톱·모바일 성능 E2E에 5·6(곰 압착·찾기 전환)의 `loadMs`·전환 구간 프레임 간격을 **기록만** 하는 측정을 추가한다. 새 단언 예산은 두지 않는다. - 기준선 결과는 계획 문서에 기록하고, 이를 근거로 4.1의 분할 경로와 항목 순서를 정한다. ### 6.2 완료 기준 - 실기기: `scripts/device-ab.sh <기준 커밋> <변경 커밋>`으로 번갈아 3회씩 잰다. 장면별 지표의 중앙값 전·후 값과 개선 폭을 계획 문서와 `PROGRESS.md`에 기록한다. 절대 예산은 없다. - 절단 장면(1·2): 해당 구간 `maxGapMs` 중앙값이 개선된다. - 홈 경로(4): (a) 시작 `loadMs` 중앙값이 개선되고 홈 표시 구간 `maxGapMs`가 기준보다 나빠지지 않는다. (b) 홈 복귀 프레임 `maxGapMs`가 개선된다. (c) PLAY 진입 구간 `maxGapMs`가 기준보다 나빠지지 않는다(즉시 PLAY는 남은 계산을 동기로 하므로 기록만 한다). - 전환 장면(3·5·6): 진입 구간이 개선되고, **전환 전체(결과 카드 + 진입)의 최대 프레임 간격 중앙값이 기준보다 나빠지지 않는다**. 결과 카드 구간 자체가 조금 나빠지는 것은 이 조건 안에서 허용한다. - 조건을 못 지킨 장면은 원인과 함께 기록하고 머지 전에 사용자에게 알린다. - 에뮬레이션: 기존 `perf.spec` 지표가 `refactor-baseline.md` 6절 규칙(중앙값 max(1ms, 20%))으로 회귀하지 않는다. 예산은 완화하지 않는다. - 단위 전체·빌드 통과, `npm run e2e:mobile` 통과. 5절 동치 테스트 추가. - **외관 변화 없음.** 시각 검수 방식 선택은 필요 없다. 구현 중 외관이 바뀌는 수정이 필요해지면 멈추고 시각 검수 방식을 사용자에게 묻는다. - Native/SDD 코드 리뷰와 최종 독립 리뷰를 유지한다. - 마무리: 리뷰 통과 후 `PROGRESS.md`를 갱신하고 로컬 main에 머지한다. 푸시하지 않고 "main 반영·미배포"로 적는다. ## 7. 비목표와 피해야 할 결과 비목표: - Web Worker 이관(접근 2) - 홈에서 바로 진입하는 판의 테이블 젤리 동기 생성(기존 동작 유지. 이 경로는 첫 가이드만 대상이다) - 압착 장비 모델 팩토리의 프레임 분할(재사용으로 대신한다) - 상점·장비 미리보기의 모델 생성 경로 - 휘둘러 베기 준비 큐 변경(이미 행 묶음 분할) - 하위 프로젝트 C 항목(DOM 레이아웃 읽기, 천천히 썰기 할당, 투과 패스, 천천히 썰기 등급 하향 재컴파일) - iPhone 측정(미완료로 기록) - main 푸시·운영 배포 피해야 할 결과: - 외관·채점·절단 판정 결과 변화 - 성능 예산·메시 품질 임계값 완화 - 입력 처리 중 동기 메시 생성(`inputMeshBuilds` > 0) - 준비 자원의 누수(넘겨받지 못한 모델·메시·텍스처 미해제) - 기억 캐시가 판이 끝나도 큰 자원을 계속 쥐는 것(`WeakMap` 키 수명에 맡기고 강한 참조 전역 맵을 쓰지 않는다) - 결과 카드 동안 준비가 늘어 전환 전체의 최대 끊김이 커지는 것(6.2) - 재사용 압착 모델에 이전 판의 눌림 자세·연출 상태가 남는 것 - 기기 측정이 기본 E2E·CI에 섞이는 것 ## 8. 빌더에게 맡기는 것 - 0단계 대표 판 선택과 장면 스크립트 구현 - 4.1 행 단위 판정의 구체 구현과 경계 규칙 맞추기, 분할 경로·묶음 크기 - 4.2 옵션을 키에 반영하는 방식 - 4.3 미리 계산 진입점의 형태와 홈 화면의 묶음 크기 - 4.4 모델 소유·리셋의 구체 인터페이스, 젤리 준비를 넘겨받고 이어 하는 인터페이스, 곰 메시 공유 여부 - 6.1 장면의 구간 경계 표시 방법(`perfMark` 등)과 가장 무거운 압착 장비 선택 - 작업 순서(0단계 측정 결과를 근거로)