# 플레이 중 셰이더 재컴파일 제거 설계 (성능 하위 프로젝트 A) 작성일: 2026-10-28 상태: 사용자 승인(2026-10-28). 구현 계획: `docs/superpowers/plans/2026-10-28-shader-recompile-removal.md`. ## 1. 목적과 확정 요구 플레이 도중 셰이더 프로그램이 다시 컴파일되며 생기는 한 프레임 멈춤을 없앤다. 고치기 전에 그 컴파일이 실제로 언제·몇 번 일어나는지 재는 계측을 먼저 갖추고 기준선을 남긴다. 성능 개선 3개 하위 프로젝트(A: 셰이더 재컴파일, B: 절단 준비 CPU·모드 전환, C: 지속 프레임 비용·투과 패스) 중 첫 번째다. 사용자가 확정한 내용: - 0단계 계측·기준선 → 셰이더 재컴파일 제거 순서로 진행한다. - 대상은 다섯 순간이다: 각 판의 첫 절단, 틀 찍기 첫 찍기, 다른 젤리 찾기의 곰 누름, 메스로 긋는 동안(회전판 포함), FPS 기반 품질 등급 하향. - **실기기 측정을 완료 기준에 포함한다.** 기준은 설치한 앱(Capacitor Android WebView)이다. 기기는 연결된 Galaxy S22 Ultra(SM-S908N, Android 16, Adreno 730, 앱 기본 등급 medium)다. - 실기기 성공 기준에 **절대 ms 예산을 두지 않는다.** 해당 순간의 그리기 중 컴파일 0회와, 고치기 전·후 APK 번갈아 3회 측정의 최대 프레임 간격 중앙값 개선 폭을 기록한다. - 접근 1(셰이더 보관소 + 미리 컴파일)을 택했다(3절). - iPhone은 Playwright 자동 조작이 불가능하므로 이번 범위에서 제외하고 미완료로 남긴다. - 작업 브랜치에서 구현하고, 완료 후 **로컬 main에 머지까지만** 한다. main 푸시(=운영 배포)는 하지 않는다. 컴파일 카운터는 운영 빌드에도 들어가는 코드이며, 배포는 사용자가 따로 결정한다. ## 2. 현재 구조와 원인 three 0.186은 재질이 `dispose`되면 그 재질이 쓰던 프로그램의 사용 수를 줄이고, 0이 되면 프로그램을 파기한다(`WebGLPrograms.releaseProgram`). 같은 키의 재질이 다음에 그려질 때 그리기 안에서 동기로 compile·link 하고 결과를 확인하므로 그 프레임이 멈춘다. 처음 보는 변형(define·재질 플래그 조합)도 첫 그리기에서 같은 일이 생긴다. 이미 이 문제를 아는 코드가 있다: `Game.prepareTable`은 `compileAsync`와 1e-4 크기 예열 그리기를 하고, `SwipeMode`는 `swipeWarm`에 재질을 쥐고 있다. | 순간 | 위치 | 원인 | |---|---|---| | 각 판의 첫 절단 | `src/core/PieceSplitter.ts:68` `swap` | `pieces.remove(sourceId)`가 원본 재질을 먼저 해제한다. 그 키를 쓰는 유일한 재질이라 프로그램이 파기되고, 바로 만든 새 조각 재질(같은 키)이 다음 그리기에서 다시 컴파일된다 | | 틀 찍기 첫 찍기 | `src/core/modes/StampMode.ts:391` | 테이블 준비는 조각 0(구멍 변형)만 컴파일한다. 찍혀 나온 조각의 구멍 없는 변형은 `onCut` 프레임에 처음 그려진다 | | 찾기 곰 누름 | `src/core/modes/OddJellyMode.ts:183` | `clearcoat=0.16*(1-amount)`가 0이 되면 `HAS_CLEARCOAT`가 바뀌어 새 프로그램이 필요하다. 준비의 `compileAsync`에 그 변형이 없다 | | 메스로 긋는 동안 | `src/cutting/ScalpelGroove.ts:129` `setPath`, `src/core/knife/ScalpelCutFx.ts:57·74` | 점 수가 바뀔 때마다 기존 칼자국 재질을 모두 해제한 뒤 새 `ShaderMaterial`을 만든다. 회전판에서는 각도가 계속 바뀌어 손이 멈춰도 매 프레임 다시 만든다. 칼자국 셰이더는 미리 컴파일되지 않는다 | | 등급 하향 | `src/core/Game.ts:1079` `setTier` | `FpsGovernor`(평균 45fps 미만 3초)가 한 단계 내리면 모든 조각 재질을 동기로 새로 만들어 교체한다. 레이마치 단계 수가 셰이더 상수라 전부 새 프로그램이다. S22에서 75~208ms 멈춤이 기록돼 있다 | 기존 성능 지표(`prepMaxMs`·`strokeMaxMs`·`loadMs`·`dropMaxGapMs`)는 이 순간들을 재지 않는다. `strokeMaxMs`는 손을 뗀 뒤 `submitStroke`만 재고, `renderInfo().programs`는 개수만 보여 파기 후 재생성을 구분하지 못한다. Chrome은 컴파일 결과를 캐시하므로 데스크톱에서는 재컴파일의 ms가 작게 보일 수 있다. 그래서 회귀 기준은 **컴파일 횟수**, 크기는 **실기기**에서 잰다. ## 3. 접근 방식 - **접근 1(채택): 셰이더 보관소 + 미리 컴파일.** 판이 떠 있는 동안 필요한 프로그램마다 대표 재질 하나를 해제하지 않고 쥔다(`swipeWarm` 방식의 일반화). 실제 재질을 버려도 프로그램 사용 수가 0이 되지 않는다. 처음 보는 변형은 미리 `compileAsync`해 보관소에 넣는다. 다섯 순간을 한 방식으로 처리하고 변경이 작다. 칼자국을 다시 만들 때의 객체·버퍼 할당은 남는다. - 접근 2(기각): 항목별 개별 수정(원본 해제 지연, 칼자국 재질 풀·버퍼 제자리 갱신, 항목별 미리 컴파일). 할당까지 줄지만 고칠 곳이 흩어지고 칼자국 변경 위험이 크다. 칼자국 할당 줄이기는 하위 프로젝트 C로 넘긴다. - 계측 위치: 테스트 쪽 스크립트 주입 대신 **게임 디버그 API**(`window.__jellyDebug`)에 둔다. 이미 떠 있는 앱 WebView에 붙는 기기 측정과 콘솔 수동 확인에서도 같은 값을 읽기 위해서다. ## 4. 계측 설계 (0단계) ### 4.1 컴파일 카운터 - `Game`이 렌더러의 WebGL 컨텍스트 **인스턴스**의 `linkProgram`·`compileShader`를 감싸 호출 수를 센다(프로토타입이 아니라 게임 컨텍스트만). 컨텍스트 복구 뒤에도 같은 객체이므로 감싼 상태가 유지되는지 확인한다. - 각 link를 **그리기 안**(렌더 호출 중)과 **그리기 밖**(`compileAsync` 등)으로 나눠 센다. `RenderManager.render` 진입·종료로 표시한다(그리기는 이곳 한 곳뿐이며 디버그 `renderNow`도 이를 거친다). `compileAsync`도 `linkProgram`을 부르지만 비차단(KHR_parallel_shader_compile)이라 멈춤이 아니다. - 그리기 안 link가 0이어도 멈출 수 있다. three는 프로그램을 그리기에서 처음 쓸 때(`onFirstUse`) `LINK_STATUS`와 로그를 동기로 조회한다. 그리기 밖에서 link했지만 아직 끝나지 않은 프로그램이면 이 조회가 끝날 때까지 기다린다. 그래서 카운터는 **그리기 안 미완료 첫 사용**도 센다. 그리기 안에서 `getProgramParameter(p, LINK_STATUS)`가 불리면, 확장이 있을 때 먼저 `COMPLETION_STATUS_KHR`를 조회해 거짓인 경우만 센다. - **완료 기준은 그리기 안 막힘(link + 미완료 첫 사용) 0회다.** 확장이 없는 환경에서는 `compileAsync`가 실제 완료를 확인하지 않으므로 미완료 첫 사용을 구분할 수 없다. 그 환경(WebKit 가능성)의 단언은 그리기 안 link만 보는 약한 검사이며, 그렇다고 기록한다. (구현 판단) - 항상 켠다(운영 빌드 포함). 비용은 호출당 정수 증가뿐이다. ### 4.2 프레임 기록과 구간 질의 - 프레임마다 실제 프레임 간격(ms)과 그 프레임의 그리기 안·밖 link 수를 고정 길이 링 버퍼(약 600프레임)에 남긴다. - 디버그 API 추가(이름은 빌더가 정하되 `JellyDebug` 타입·`e2e/debug-api.spec.ts`를 함께 갱신): - `shaderLinks()` → `{ inRender, inRenderPending, outside, shaders }` 누적 - `perfMark()` → 표식 id, `perfSince(id)` → `{ inRenderLinks, inRenderPending, outsideLinks, maxGapMs, frames, tierChanged }` - `requestTier(t)`·`tierPending()`(6절 5번) - `shadersReady()`: 지금 판의 보관소 등록이 끝났는지(6절 0번) - `holdTier(on)`: 측정 중 `FpsGovernor`의 자동 하향을 멈춘다. 디버그 전용이며 정책 변경이 아니다. 등급 하향 시나리오 외의 측정은 이를 켜고, 그래도 등급이 바뀐 실행은 버린다(`tierChanged`). (구현 판단) - 기존 `forceTier`는 **동기 그대로** 둔다. 이를 쓰는 기존 E2E 6개 파일의 계약을 바꾸지 않는다. ### 4.3 시나리오와 E2E 새 파일 `e2e/shader-compile.spec.ts`(기능 프로젝트에서 실행, 벽시계 단언 없음). 각 시나리오는 표식을 찍고 구간의 그리기 안 link를 단언한다. 레벨 번호는 표시 순서 기준이며, 빌더가 같은 성격의 판으로 바꿀 수 있다. | 시나리오 | 판 | 구간 | |---|---|---| | 첫 절단 | 1 | 첫 획 시작 → 조각 교체 후 `ready` 또는 `result` | | 첫 찍기 | 40 | 첫 탭 → 조각 생성 후 10프레임 | | 곰 누름 | 66 | 첫 선택 → 누름 주기 끝 | | 메스 긋기 | 14, 회전판 메스 판(70 등) | 누름 → 여러 점 이동 → 회전판은 멈춘 채 1초 유지 → 손 뗌 | | 등급 하향 | 1, 40, 66, 59 | `requestTier` → 교체 확정 후 10프레임 | - **0단계에서는 이 E2E가 실패하는 것이 정상이다.** 실패 시 그리기 안 link 수를 기준선으로 기록한다. - `e2e/perf.spec.ts`에 같은 시나리오의 `maxGapMs`를 `[perf]` 줄로 기록한다. 단언은 두지 않는다. - WebKit에서도 카운터가 동작하면 단언을 포함한다. 동작하지 않으면 Chromium 전용으로 두고 이유를 남긴다. ### 4.4 실기기 측정 경로 - 별도 설정 `playwright.device.config.ts`(기본 `npm run e2e`·`e2e:mobile`에 포함하지 않음, workers 1). - 픽스처: 1. `adb`로 `game.steadyloop.jelly`의 pid와 `webview_devtools_remote_` 소켓을 찾는다. 2. 빈 포트로 `adb forward` 한 뒤 `chromium.connectOverCDP`로 붙고, 기존 페이지를 `page`로 넘긴다. 3. 종료 때 **앱을 닫지 않고 연결만 끊고** forward를 지운다. - 2026-10-28 탐색에서 연결, `__jellyDebug` 읽기, `https://localhost/?level=21&quality=medium` 직접 진입을 확인했다(384×823, DPR 2.81). 연결을 끊지 않으면 node 프로세스가 끝나지 않았다. - 기기 시나리오 `e2e/device/shader-device.spec.ts`: 4.3과 같은 시나리오를 `quality=medium`으로 돌린다. 그리기 안 link 수, `maxGapMs`, 측정 시 배터리 온도(`dumpsys battery`)를 `[device]` 줄로 남긴다. 입력은 기존 E2E 도우미를 재사용한다. 앱 WebView에서 포인터 입력이 동작하는지는 빌더가 먼저 확인한다. - A/B 스크립트 `scripts/device-ab.sh`(macOS bash 3.2 호환): 1. 기준 커밋을 `git worktree add --detach`로 꺼내 `node_modules`를 링크한다. 2. 기준·변경 디버그 APK를 각각 빌드한다(JDK 21: Android Studio JBR). 3. `adb install -r`로 번갈아 설치하며 기기 프로젝트를 3회씩 돌린다(같은 디버그 서명이라 덮어쓰기 가능). 4. `[device]` 줄을 `test-results/device-ab/`에 모으고, 끝나면 worktree를 지운다. - **A/B 기준 커밋은 계측 커밋이다**(수정 전 + 계측 포함). 계측 없는 APK는 비교할 값이 없기 때문이다. - GPU 프로그램 캐시: WebView가 앱 데이터에 셰이더 디스크 캐시를 남긴다면, `install -r`로 데이터가 유지되어 기준·변경 APK가 캐시를 공유한다. 그러면 실행 순서에 따라 편향이 생긴다. 빌더는 먼저 캐시가 있는지(앱 데이터 아래 GPU 캐시 디렉터리) 확인한다. - 캐시가 있으면 매 실행 전에 **그 캐시만** `run-as`로 지우고, 저장 데이터는 남긴다. 이렇게 재는 값이 주 지표(처음 실행 조건)다. - 데워진 캐시 상태는 회차마다 같은 실행을 한 번 더 돌려 보조 지표로 기록한다. - 캐시가 없으면 이 단계를 생략하고 그렇게 기록한다. (구현 판단) - 에뮬레이터는 쓰지 않는다(AGENTS 기기 정책). 기기가 없으면 기기 측정을 미완료로 남긴다. ## 5. 셰이더 보관소 - `src/core/`에 새 클래스(가칭 `ShaderKeeper`)를 두고 `Game`이 소유한다. `ModeHost`로 모드에 노출한다. 서비스처럼 레벨 모드를 모른다. 키는 호출자가 정한다. - `keep(key, material, warmMesh)`: 1. `compileAsync`로 컴파일한다. 2. 실제 메시와 **같은 정점 속성(jelly는 position·normal·aCut)과 객체 플래그(castShadow·receiveShadow)**를 가진 1e-4 크기 메시로 두 프레임 그린다. 드라이버가 첫 그리기에서 파이프라인을 마저 만들기 때문이다(`prepareTable`·`swipeWarm`의 기존 근거). 3. 그 뒤 재질을 해제하지 않고 쥔다. - 이미 있는 키는 다시 하지 않는다. 컴파일된 재질만 프로그램 사용 수를 붙잡으므로 등록 재질은 반드시 컴파일을 거친다. - 그림자 깊이 재질(`customDepthMaterial`: 압착·천천히 썰기 형상)도 해당 형상이면 함께 쥔다. - `compileAsync`는 그림자 패스를 돌지 않는다. 그래서 깊이 프로그램은 예열 그리기 안에서 link된다. 이는 준비 시점이므로 측정 구간 밖이다. - 깊이 재질 보관은 필수다. `replaceMaterial`이 옛 깊이 재질을 먼저 해제하므로, 보관하지 않으면 등급 교체 때 압착 그림자 프로그램이 다시 link된다. - 압착 그림자 키(`jelly-press-shadow-*`)는 등급과 무관하므로 판 준비 때 한 번 쥐면 된다. - 등록 재질의 키는 **실제로 교체될 재질과 같아야 한다.** 예를 들어 첫 절단은 `PieceSplitter.swap`이 만드는 옵션 없는 `createJellyMaterial(type, tier)`와 같은 재질을 쥔다. 테이블 재질과 같은 옵션을 기준으로 하지 않는다. - 수명: - 현재 판과 준비 중인 다음 판에 필요한 키만 유지한다. 판을 불러올 때 더 이상 필요 없는 jelly 키를 해제한다. - 칼자국과 장비 연출처럼 판과 무관한 키는 유지한다. - 등급 교체가 확정되면 옛 등급 키를 해제한다. - 컨텍스트 손실 때 전부 비우고, 복구 뒤 `reprepare` 흐름에서 다시 채운다(장비 연출 `forgetPrecompile`과 같은 방식). - 모든 jelly 종류×변형을 한꺼번에 쥐지 않는다. 모바일 GPU 메모리와 프로그램 수를 늘리지 않기 위해서다. - `SwipeMode.swipeWarm`은 이번에 옮기지 않는다(이미 동작하고, 옮기면 휘둘러 베기 회귀 위험만 생긴다). ## 6. 순간별 적용 0. **등록 시점(모든 진입 경로)**: 등록은 두 경로 모두에서 한다. 결과 카드 중 다음 판을 준비하는 `prepareTable`과, `?level=`·메뉴에서 바로 들어갈 때의 `loadLevel`이다. 메뉴의 Play가 실제 사용자의 주 경로이기 때문이다. - `loadLevel` 경로는 비동기로 등록하고 **입력을 막지 않는다.** 입력 가능 시점이 바뀌면 조작감이 달라지고 기존 E2E 타이밍도 흔들리기 때문이다. - 등록이 끝나기 전에 첫 절단이 오면 지금과 같은 동작(재컴파일)으로 떨어진다. 사람이 첫 획을 끝내기까지 걸리는 시간에 비해 등록은 짧으므로 실제로는 드물다. - E2E와 기기 측정은 `shadersReady()`를 기다린 뒤 측정한다. (구현 판단) 1. **첫 절단**: 테이블 재질을 준비할 때 `swap`이 만들 옵션 없는 재질(같은 종류·등급)을 보관소에 등록한다. 원본 해제 순서는 바꾸지 않는다. 칼 모드의 반 가르기·다시 자르기·회전판에 공통으로 적용된다. 2. **틀 찍기**: 틀 찍기 판의 준비에서 구멍 없는 변형(`materialOptions(level, id≠0)`)도 등록한다. 3. **곰 누름**: 찾기 판 준비에서 같은 옵션의 clearcoat 0 변형을 등록한다. clearcoat 하한을 바꾸는 방법은 외관이 달라지므로 쓰지 않는다. 4. **메스 칼자국**: 메스를 쓰는 판(곡선·회전판 메스·혼합 판)의 준비에서 칼자국 `ShaderMaterial`을 한 번 등록한다. 같은 transparent·depthWrite·side 설정과 칼자국 지오메트리 속성을 쓴다. - 이 재질은 **같은 셰이더 소스 문자열**을 써야 하고 게임 내내 해제하지 않는다. three의 `WebGLShaderCache`는 같은 소스를 쓰는 재질이 하나라도 살아 있을 때만 단계 id를 유지하므로, 전부 해제되면 id가 새로 매겨져 프로그램 키가 바뀐다. - 투명·양면이라 `compileAsync`가 BackSide·FrontSide 두 프로그램을 만드는데, 둘 다 이 재질에 묶인다. `setPath`의 재생성 구조와 회전판 매 프레임 재생성은 그대로 둔다. 할당 문제는 C로 넘긴다. 5. **등급 하향(비동기)**: - `FpsGovernor`의 결과와 새 디버그 `requestTier(t)`는 `Game.requestTier`로 들어간다. - `requestTier`의 흐름: 1. 대기 중인 같은 등급이 있으면 무시한다. 2. 지금 판의 조각들이 쓰는 (종류, 옵션) 조합과 해당 모드의 추가 변형(2·3·4번)을 새 등급으로 보관소에 등록한다. 3. 모두 끝났을 때 아직 같은 요청이 유효하면 기존 `setTier` 본문으로 확정한다. - 대기 중에는 옛 등급으로 그린다. - 대기 중에 시작된 다음 판 준비는 기존 `stalePrepare` 규칙대로 확정 후 다시 준비된다. - 컨텍스트 손실 시 대기를 취소한다. 복구 뒤 `governor.current`와 `tier`가 다르면 다시 요청한다. - `forceTier`는 동기 유지다. - 휘둘러 베기(이미 비동기 재예열)와 천천히 썰기의 `onTierChange`가 일으키는 컴파일은 측정해 기록한다. 같은 보관소 등록으로 막을 수 있으면 포함하고, 아니면 B·C로 넘긴다. 확정 프레임 0회 단언은 1·40·66·59판에 둔다. 6. **계측에서 추가로 발견된 플레이 중 컴파일**(예: `loadLevel`에서 새로 만드는 절단 가이드)은 목록으로 남긴다. 보관소 등록만으로 막히는 것은 포함하고, 그 외는 B·C 후보로 넘긴다. ## 7. 설계 독립 검토 기록 2026-10-28 새 컨텍스트 빌더 점검. three 0.186 소스와 대조해 다음 전제가 맞다고 확인했다. - 재질 하나를 쥐면 `usedTimes`가 유지된다. - 그리기 안·밖 link를 구분할 수 있다. - 첫 절단 키가 일치한다. - 곰 누름의 원인 진단이 맞다. 다음 지적은 반영했다(위 해당 절에 "구현 판단"으로 표시). - 직접·메뉴 진입 경로(6절 0번) - 미완료 첫 사용 계측(4.1) - 측정 중 자동 하향 차단(4.2) - 기기 GPU 캐시 편향(4.4) - 깊이 재질과 칼자국 소스 키 조건(5절, 6절 4번) ## 8. 검증과 완료 기준 - `npm test`·`npm run build` 통과. 카운터·구간 질의 로직은 가짜 GL 객체로 단위 테스트한다. - `e2e/shader-compile.spec.ts`: 4.3 시나리오의 그리기 안 link 0회(데스크톱·모바일 Chromium, 가능하면 WebKit). - `e2e/debug-api.spec.ts` 갱신, `npm run e2e:mobile` 통과. - 데스크톱·모바일 에뮬레이션 성능 A/B(계측 커밋 대 수정 커밋): 기존 `perf.spec` 지표가 `refactor-baseline.md` 6절 규칙(중앙값 max(1ms, 20%))으로 회귀하지 않을 것. 새 `maxGapMs`는 기록만 한다. 예산은 완화하지 않는다. - 실기기: `scripts/device-ab.sh`로 기준·변경 APK를 번갈아 3회씩 측정한다. - 변경 APK에서 다섯 순간의 그리기 안 link가 0회여야 한다. - 순간별 `maxGapMs` 중앙값의 전·후 값과 개선 폭을 계획 문서와 `PROGRESS.md`에 기록한다. 절대 예산은 없다. - **외관 변화가 없어야 한다.** 모든 수정은 컴파일 시점만 옮긴다. 구현 중 외관이 달라지는 수정이 필요해지면 멈추고 시각 검수 방식을 사용자에게 다시 묻는다. - Native/SDD 코드 리뷰와 최종 독립 리뷰는 AGENTS.md 기본값을 따른다. - 마무리: 리뷰 통과 후 `PROGRESS.md`를 갱신하고 로컬 main에 머지한다. 푸시하지 않으며, `PROGRESS.md`에 "main 반영·미배포"로 적는다. ## 9. 비목표와 피해야 할 결과 비목표: - 칼자국 할당 줄이기(C) - 절단 준비 CPU·SDF·메시 코어 캐시·모드 전환(B) - 천천히 썰기 투과 패스(C) - iPhone 측정(미완료로 기록) - `FpsGovernor` 임계값·정책 변경 - `swipeWarm` 이전 - main 푸시·운영 배포 피해야 할 결과: - 외관 변화 - 기존 `forceTier` 동기 계약 파괴 - 성능 예산 완화 - 기기 테스트가 기본 E2E·CI에 섞이는 것 - 보관소가 모든 종류를 쥐어 모바일 메모리가 느는 것 ## 10. 빌더에게 맡기는 것 - 디버그 API 이름과 링 버퍼 길이 - 보관소 클래스 이름과 `ModeHost` 노출 형태 - 시나리오별 정확한 판 번호(같은 성격 유지)와 구간 끝 판정 - 기기 픽스처의 포트 선택과 소켓 탐색 방법 - APK 빌드 명령의 세부(Gradle 작업, `cap sync` 위치) - 6번 추가 발견 항목의 포함 여부(위 규칙 안에서)