# 플레이 중 셰이더 재컴파일 제거 구현 계획 (성능 하위 프로젝트 A) > **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:** 플레이 중 다섯 순간(첫 절단·첫 찍기·곰 누름·메스 긋기·등급 하향)의 그리기 안 셰이더 컴파일 멈춤을 계측하고 0회로 만든다. 실기기 앱에서 전후를 잰다. **Architecture:** 렌더러 GL 컨텍스트를 감싼 `ShaderStats`가 그리기 안·밖 link와 미완료 첫 사용, 프레임 간격을 기록하고 디버그 API로 노출한다. `ShaderKeeper`는 판에 필요한 셰이더 변형마다 대표 재질 하나를 미리 컴파일해 쥐고 있어, 실제 재질을 버려도 프로그램이 살아 있게 한다. 등급 하향은 새 등급 변형을 보관소에 먼저 등록한 뒤 확정한다. **Tech Stack:** TypeScript strict, three 0.186.1(WebGL2), Vitest, Playwright 1.64(`connectOverCDP`로 Android WebView 연결), Capacitor 8 Android 디버그 APK, adb. **Spec:** `docs/superpowers/specs/2026-10-28-shader-recompile-removal-design.md` ## Global Constraints - 외관 변화 없음. 모든 수정은 컴파일 시점만 옮긴다. 외관이 바뀌는 수정이 필요해지면 멈추고 사용자에게 시각 검수 방식을 묻는다. - 완료 기준: 측정 구간의 그리기 안 막힘(`inRenderLinks + inRenderPending`) 0회. - 기존 `forceTier`는 동기 그대로 둔다(기존 E2E 6개 파일 계약). - 성능 예산(`perf.spec.ts`의 8ms·16ms·34ms, 75판 모바일 10·20ms)은 완화하지 않는다. 회귀 판정은 `docs/superpowers/refactor-baseline.md` 6절(같은 시각 A/B, 중앙값 max(1ms, 20%))이다. - 실기기에는 절대 ms 예산이 없다. 기준·변경 APK를 번갈아 3회씩 재서 중앙값과 개선 폭만 기록한다. - 기기 테스트는 기본 `npm run e2e`·`e2e:mobile`·CI에 섞지 않는다. Android 에뮬레이터는 쓰지 않는다. - 셸 스크립트는 macOS bash 3.2 호환(연관 배열 없음). Android 빌드 JDK는 `/Applications/Android Studio.app/Contents/jbr/Contents/Home`이다. - 보관소는 현재 판과 준비 중인 다음 판에 필요한 키만 유지한다. 칼자국 재질은 게임 내내 해제하지 않는다. - `SwipeMode.swipeWarm`은 옮기지 않는다. `FpsGovernor` 임계값(45fps·3초)은 바꾸지 않는다. - `window.__jellyDebug` 키를 추가하면 `src/core/JellyDebug.ts` 타입과 `e2e/debug-api.spec.ts`의 `EXPECTED_KEYS`를 함께 갱신한다. - 작업 브랜치에서 구현하고, 마지막에 로컬 main에 머지만 한다. **푸시하지 않는다**(main 푸시 = 운영 배포). - 코드 주석·커밋 메시지는 한국어. ## Review Focus 1. 컨텍스트 손실이 진행 중인 `keep` 도중에 오면, 손실 뒤 끝난 `keep`이 옛 재질을 다시 등록하지 않아야 한다. Task 4에서 `clear 뒤 끝난 keep 은 등록되지 않는다` 단위 테스트로 고정한다. 2. 같은 키를 다른 판이 다시 요청하면(같은 젤리 종류가 연속), 판을 바꿀 때의 해제가 그 키를 지우지 않아야 한다. Task 4의 `같은 키 재요청은 범위를 새 판으로 옮긴다` 테스트로 고정한다. 3. 긴 세션에서 보관소가 프로그램 수를 계속 늘리지 않아야 한다. 또 비동기 등록 때문에 `memory.spec.ts`의 바퀴 비교가 흔들리지 않아야 한다. Task 5에서 `memory.spec.ts`의 `lap`이 `shadersReady()`를 기다리게 하고 통과를 확인한다. 4. 등급 요청을 기다리는 중에 판이 바뀌면, 확정 시점에 새 판의 변형도 등록돼 있어야 한다. Task 8의 E2E `등급 요청 대기 중 판을 바꿔도 확정 프레임 막힘 0`으로 고정한다. 5. `KHR_parallel_shader_compile`이 없는 환경(WebKit 가능성)에서는 미완료 첫 사용을 셀 수 없다. 단언이 약해진다는 사실이 숨겨지면 안 된다. Task 1의 `확장이 없으면 pending 을 세지 않고 hasParallelCompile 이 거짓` 단위 테스트와, Task 2 E2E의 WebKit 주석으로 고정한다. --- ### Task 1: `ShaderStats` 카운터·프레임 기록 **Files:** - Create: `src/core/ShaderStats.ts` - Test: `tests/ShaderStats.test.ts` **Interfaces:** - Produces: ```ts export interface ShaderCounts { inRender: number; inRenderPending: number; outside: number; shaders: number } export interface PerfWindow { inRenderLinks: number; inRenderPending: number; outsideLinks: number; maxGapMs: number; frames: number; tierChanged: boolean } export class ShaderStats { constructor(capacity?: number) // 기본 600프레임 attach(gl: WebGL2RenderingContext): void // 인스턴스의 linkProgram·compileShader·getProgramParameter를 감싼다. 두 번 불러도 한 번만 감싼다 readonly hasParallelCompile: boolean // attach 때 KHR_parallel_shader_compile 확장 유무 beginRender(): void endRender(): void frame(gapMs: number, tier: string): void // 프레임 하나 기록(링 버퍼) counts(): ShaderCounts mark(): number // 표식 id since(id: number): PerfWindow // 표식 뒤 기록된 프레임들의 합·최대. 모르는 id면 모두 0 } ``` - 감싸는 규칙: - `linkProgram`: 그리기 중이면 `inRender`, 아니면 `outside`를 하나 올린다. - `compileShader`: `shaders`를 올린다. - `getProgramParameter(p, LINK_STATUS)`: 그리기 중이고 확장이 있을 때, 원래 함수로 `COMPLETION_STATUS_KHR`를 먼저 조회한다. 거짓이면 `inRenderPending`을 올린다. - 프레임 기록에는 그 프레임 동안 늘어난 카운트를 담는다. - `since`는 링 버퍼에 남은 프레임만 본다(용량을 넘기면 오래된 프레임은 빠진다). - `tierChanged`는 표식 뒤 프레임의 tier가 표식 시점과 다른 것이 하나라도 있으면 참이다. - [ ] **Step 1: 실패하는 단위 테스트 작성** (`tests/ShaderStats.test.ts`, 가짜 GL 객체: `LINK_STATUS`·확장 상수와 호출 기록용 함수, 확장 유무를 선택할 수 있게) - `그리기 안·밖 link 를 나눠 센다`: `outside` link 1회 → `beginRender`, link 2회 → `endRender` → `counts()`가 `{ inRender: 2, outside: 1, inRenderPending: 0, shaders: 0 }` - `확장이 있으면 그리기 안 미완료 첫 사용을 센다`: 완료 상태가 거짓인 프로그램에 그리기 안에서 `getProgramParameter(p, LINK_STATUS)` → `inRenderPending === 1`. 완료 상태가 참이면 0. 그리기 밖에서는 0 - `확장이 없으면 pending 을 세지 않고 hasParallelCompile 이 거짓` - `원래 함수의 반환값을 그대로 돌려준다`(감싼 `getProgramParameter`가 원래 값 반환) - `attach 를 두 번 해도 한 번만 센다` - `since 는 표식 뒤 프레임의 link 합과 최대 간격을 낸다`: mark → `frame(16,'medium')`, 그리기 안 link 1회 뒤 `frame(80,'medium')` → `{ inRenderLinks: 1, maxGapMs: 80, frames: 2, tierChanged: false }` - `등급이 바뀐 프레임이 있으면 tierChanged` - `용량을 넘기면 오래된 프레임은 since 에서 빠진다`(capacity 3, 프레임 5개) - [ ] **Step 2: 실패 확인** — Run: `npx vitest run tests/ShaderStats.test.ts` / Expected: FAIL(모듈 없음) - [ ] **Step 3: `src/core/ShaderStats.ts` 구현** — three를 import하지 않는다(순수 GL 래퍼). 링 버퍼는 고정 길이 배열과 증가하는 프레임 번호로 구현한다. 표식은 `{ seq, tier }`를 맵에 저장한다. - [ ] **Step 4: 통과 확인** — Run: `npx vitest run tests/ShaderStats.test.ts` / Expected: PASS - [ ] **Step 5: 커밋** — `git add src/core/ShaderStats.ts tests/ShaderStats.test.ts && git commit -m "셰이더 컴파일·프레임 간격 계측기 추가"` ### Task 2: 게임 연결·디버그 API·기준선 E2E **Files:** - Modify: `src/core/RenderManager.ts:53` (`render`에 그리기 표시 훅) - Modify: `src/core/Game.ts` (생성자에서 `ShaderStats` 생성·`attach`, `frame` 루프 `governor.sample` 자리(:1057)에 기록과 `holdTier` 분기, 디버그 문맥 연결) - Modify: `src/core/GameDebug.ts` (`AppDebug`·`AppDebugDeps` 또는 `GameDebugContext`에 추가), `src/core/JellyDebug.ts` - Modify: `tests/GameDebug.test.ts`, `e2e/debug-api.spec.ts` (`EXPECTED_KEYS`에 `holdTier`·`perfMark`·`perfSince`·`shaderLinks`) - Create: `e2e/shader-compile.spec.ts`, `e2e/helpers/shader.ts` (`e2e/helpers.ts`에서 다시 내보냄) - Modify: `e2e/perf.spec.ts` (기록 전용 테스트 추가) **Interfaces:** - Consumes: Task 1 `ShaderStats` - Produces: - `RenderManager.renderHooks: { begin(): void; end(): void } | null`. `render`가 `renderer.render` 앞뒤로 부른다. - 디버그 API: - `shaderLinks(): ShaderCounts` - `perfMark(): number` - `perfSince(id: number): PerfWindow` - `holdTier(on: boolean): void`: 켜면 `governor.sample`을 부르지 않는다. 기본은 꺼짐이다. - E2E 도우미(`e2e/helpers/shader.ts`): - `markPerf(page): Promise` - `perfSince(page, id): Promise` - `blocking(w: PerfWindow): number` = `w.inRenderLinks + w.inRenderPending` - `logPerf(info, label, w)`: `[perf]