최종 갱신: 2026-08-07
이 문서는 탐험과 전투 연출을 바꿀 때 지켜야 할 프레임 예산과 측정 절차를 기록한다. 감각적인 "부드러움"만으로 판단하지 않고, 릴리스와 프로파일 빌드의 프레임 분포로 회귀 여부를 확인한다.
- 60Hz 기기는 UI와 래스터 작업을 합쳐 프레임당 16.7ms 안에 끝내는 것을 기본으로 한다.
- 120Hz 기기에서는 8.3ms를 목표로 하되, 고주사율에서 품질을 낮추기보다 먼저 불필요한 빌드·레이아웃·페인트를 제거한다.
- 핵심 입력과 전투 연출은 평균값만 보지 않는다. p95, p99, 최악 프레임과 20ms·33.4ms 초과 프레임 수를 함께 기록한다.
- 디버그 빌드는 JIT와 진단 오버헤드가 있으므로 성능 판정에 사용하지 않는다.
- 배경, 플레이 캐릭터, 수호자, 전투 이펙트, HUD는 각자 필요한 애니메이션만 구독한다. 공통 상위 위젯에서 ambient와 action controller를 합쳐 전체 무대를 매 프레임 다시 빌드하지 않는다.
- 정적인 원화와 캐릭터는
AnimatedBuilder.child와RepaintBoundary안에 둔다. 타임라인은 위치·투명도만 갱신하고, 캐릭터 스프라이트를 매 틱 새로 구성하지 않는다. - 광선, 충돌, 피해 파동처럼 짧고 반복되는 시각 효과는 여러 장식 위젯 대신 한 Canvas 레이어에서 그린다. 이펙트는 서버 판정을 표현할 뿐 피해를 다시 계산하지 않는다.
- 서버가 확정한 결과는 전투 타임라인이 끝날 때까지
pendingExpedition으로 보관한다. 전투 첫 프레임과 다음 지도·선택지 조립을 겹치지 않고, 결과 화면도 지도와 선택 패널을 한 프레임 간격으로 나눠 반영한다.
- 1600×900 원화를 화면 너비 그대로 디코드하지 않는다. 승인 원본은 보존하고 장소·통합 지형은 960px, 투명 수호자는 768px 모바일 파생본을 빌드한다. 현재 물리 픽셀 너비에 15% 여유를 둔 목표가 파생본 이하일 때만 작은 파일을 선택한다.
- 선택한 파일에도
ResizeImage를 적용하고, 장면 배경과 이동 카드는 같은 provider와ImageCache키를 공유한다. 고밀도 모바일·태블릿·데스크톱은 원본을 유지한다. - 현재 장소와 서버가 실제로 열어 준 다음 장소를 합쳐 최대 3장만 선로딩한다. 전체 지역 원화를 한꺼번에 올리면 8장 기준 약 46MB의 RGBA 메모리가 필요하므로 금지한다.
- 수호자 idle/attack/hit/defeated도 실제 표시 폭에 맞춰 디코드한다. 전투 무대 진입 시 네 상태를 비동기로 준비하되 화면 진입을 이미지 로드 완료까지 막지 않는다.
- 이동과 흔들림은 layout 속성 대신 transform을 사용한다. 투명도 애니메이션은 필요한 작은 레이어에만 적용한다.
- Android API 29 이상과 iOS의 기본 Impeller 경로를 전제로 한다. 런타임에서 임의의 셰이더 워밍업 우회 코드를 추가하지 않고, 실제 기기의 DevTools shader compilation과 raster 시간을 먼저 확인한다.
disableAnimations에서는 ambient controller를 멈추고 최종 판정 상태를 즉시 읽을 수 있어야 한다. 접근성 설정을 성능 옵션처럼 오용하지 않는다.
- Flutter 분석, 위젯 테스트, 서버 테스트를 먼저 통과시킨다.
- Flutter 도구 명령은 같은 작업 폴더에서 동시에 실행하지 않는다.
analyze → test → build순서로 실행해 산출물 경합을 피한다. - 실제 API와 DB를 연결한 390×844 릴리스 Web에서 평상시 5초와 수호전 스킬 사용 4초를 각각 측정한다.
- Chrome Performance 패널 또는 CDP로 일반 CPU, 2배 제한, 4배 제한을 확인한다. FPS, p50, p95, p99, 최대 프레임 간격, 20ms·33.4ms 초과 수를 남긴다. 창이 최소화되거나 가려져 Chrome이 rAF를 1Hz로 제한한 표본은 폐기한다.
- Android/iOS 출시 후보는 profile mode의 DevTools Performance view에서 같은 장면을 다시 측정한다. Track widget builds/layouts/paints를 필요한 구간에만 켜고, UI thread와 raster thread 중 어느 쪽이 예산을 넘겼는지 구분한다.
- 전투 종료 뒤 다음 장소 카드가 열리는 순간까지 기록한다. 애니메이션 본체가 빨라도 후속 원화 디코드가 긴 프레임을 만들면 실패로 본다.
Flutter 3.44 계열 WebAssembly 릴리스, 390×844, 로컬 실제 API 시나리오에서 측정한다. JS heap은 GC 시점에 따라 달라지므로 참고값으로만 쓰고 프레임 분포를 합격 기준으로 삼는다.
| 조건 | 구간 | FPS | p95 | p99 | 최대 | 20ms 초과 | 33.4ms 초과 |
|---|---|---|---|---|---|---|---|
| 일반 CPU, 변경 전 | 평상시 5초 | 60.08 | 16.8ms | - | 16.9ms | 0 | 0 |
| 일반 CPU, 변경 전 | 수호전 4초 | 59.95 | 16.8ms | - | 33.5ms | 1 | 1 |
| 일반 CPU, 최종 | 평상시 5초 | 59.89 | 16.8ms | 16.8ms | 16.8ms | 0 | 0 |
| 일반 CPU, 최종·콜드 캐시 | 수호전 4초 | 59.91 | 16.8ms | 16.8ms | 16.8ms | 0 | 0 |
| 일반 CPU, 수동 지휘·실제 API | 전원 명령+반격 4초 | 60.10 | 16.8ms | 16.9ms | 16.9ms | 0 | 0 |
| 일반 CPU, 2026-08-07 Wasm | 평상시 호흡·눈깜빡임 3초 | 60.02 | 16.8ms | - | 16.9ms | 0 | 0 |
| 일반 CPU, 2026-08-07 Wasm | 수동 명령+피격+반격 3초 | 60.06 | 16.8ms | - | 16.8ms | 0 | 0 |
| 일반 CPU, 2026-08-07 표준 JS | 최종 공격+처치+전환 2.5초 | 60.13 | 16.8ms | - | 16.8ms | 0 | 0 |
| CPU 2배 제한, 최종 | 수호전 4초 | 59.79 | 16.8ms | 16.8ms | 16.9ms | 0 | 0 |
| CPU 4배 제한, 변경 전 | 수호전 4초 | 59.21 | 16.8ms | - | 50.0ms | 2 | 2 |
| CPU 4배 제한, 최종 2회 범위 | 수호전 4초 | 56.23~58.95 | 16.8~33.3ms | 16.9~33.5ms | 66.6~66.7ms | 2~13 | 2~6 |
일반 CPU와 2배 제한에서는 콜드 장면 디코드, 서버 응답, 전투 종료와 다음 카드 공개를 포함해 20ms 초과 프레임이 없었다. 4배 제한은 결과 패널 조립 구간에서 33.4ms 초과가 남으므로 합격 게이트가 아니라 저사양 회귀 추적값으로 유지한다. 브라우저 측정은 실제 모바일 GPU·열 제한·메모리 압박을 대신하지 않으므로 스토어 배포 전 중급 Android 실기기와 iPhone에서 한 번 더 확인한다.
3장 절차는 릴리스 Web을 Chrome CDP로 재라고 하는데, 이번에는 그 길이 막혔다.
에이전트 세션의 브라우저 패널이 숨어 있어 Chrome이 rAF를 1Hz로 묶었다 —
40프레임을 받는 데 45초가 넘게 걸려, 3장이 폐기한다고 적어 둔 바로 그 표본이
된다. 그래서 브라우저를 우회해 Flutter의 FrameTiming을 직접 받았다.
design-system/benchmarks/effect-frame-time이 manifest의 연출을 전부
선언된 프레임 길이대로 이어 재생하며 Windows 데스크톱 profile 빌드에서 잰다.
앞 90프레임(셰이더 워밍업·첫 디코드)은 버린다.
| 회차 | 연출 | 표본 | raster p95 | raster p99 | raster 최대 | build p95 | 프레임 간격 p95 | 20ms 초과 | 33.4ms 초과 |
|---|---|---|---|---|---|---|---|---|---|
| 1회 | 69종 | 3,371 | 0.635ms | 0.846ms | 6.758ms | 0.566ms | 15.749ms | 3 | 0 |
| 2회 | 69종 | 3,370 | 0.580ms | 0.734ms | 1.175ms | 0.497ms | 15.796ms | 0 | 0 |
| 3회 | 81종 | 4,001 | 0.503ms | 0.786ms | 2.482ms | 0.436ms | 2.510ms | 0 | 0 |
| 4회 | 87종 | 4,292 | 0.582ms | 0.765ms | 2.108ms | 0.495ms | 15.899ms | 0 | 0 |
| 5회 | 90종 | 4,453 | 0.481ms | 0.613ms | 1.072ms | 0.427ms | 15.641ms | 0 | 0 |
| 6회 | 96종 | 4,646 | 0.721ms | 1.110ms | 2.358ms | 0.677ms | 16.601ms | 0 | 0 |
연출 수를 회차마다 적는 이유. 1·2회차 값이 manifest에 붙어 있는 동안 연출이
81종으로 늘어난 적이 있다. 새로 넣은 12종을 잰 적이 없는데 잰 것처럼 보였다.
지금은 verify_effect_production_gate.py가 GATE_PROFILE의 연출 수와 manifest의
연출 수를 맞춰 보고, 다르면 게이트를 떨어뜨린다.
3회차의 프레임 간격 p95 2.510ms는 그 회차만 vsync에 묶이지 않아 나온 값이다.
연출이 가벼워진 것이 아니라 화면의 리듬이 달랐던 것이고, 이래서 합격 판단에
프레임 간격을 쓰지 않는다.
읽는 법. 합격 판단에 쓰는 값은 raster와 build다. 연출 한 프레임을
그리는 데 래스터 0.6ms, 빌드 0.5ms를 쓴다 — 60Hz 예산 16.7ms의 4% 남짓이다.
프레임 간격 p95 15.8ms는 연출의 비용이 아니라 이 기계 모니터의 주사율(약
120Hz)이 만든 리듬이라 성능 지표로 읽으면 안 된다.
1회차의 20ms 초과 3 과 raster 최대 6.758ms는 워밍업을 90프레임으로 자른
뒤에도 남은 첫 텍스처 접촉으로 보인다. 2회차에서는 0이었다. 두 회차를 다
남긴다 — 좋은 쪽만 적으면 그건 측정이 아니다.
이 표가 대신하지 않는 것.
- 저사양 Android·iOS. 이 기계에는 Android SDK도 실기기도 없다(
flutter doctor가 Android toolchain 없음으로 잡는다). 데스크톱 GPU는 모바일의 발열·전력 제한·메모리 압박을 대신하지 않는다. manifest의gate_profile.pending에low_end_android_p95·ios_p95로 남겨 뒀다. - 전장 전체 프레임. 배경·엉킴 몸체·캐릭터·HUD를 뺀 연출 레이어만 그린다.
그래서 이 값은
이 에셋이 프레임 예산에서 얼마를 가져가는가이지전투 화면이 몇 프레임인가가 아니다. 에셋에 물을 수 있는 값은 전자다.
수동 지휘 실측은 241프레임의 평균 간격 16.639ms였고, 명령 확정에서
combat/turns POST가 한 번만 발생하는 것과 모든 시각 큐가 끝난 뒤 서버 결과가
반영되는 것을 같이 확인했다.
2026-08-07 회귀에서는 Wasm과 표준 JS 릴리스를 같은 390×844 시나리오로
확인했다. 최종 처치 상태는 1,100ms 동안 유지되며, 1,800ms 캡처까지 쓰러진
수호자와 승리 문구가 남고 2,000ms 캡처에서 결과 화면으로 전환됐다. 로컬 Windows의
애플리케이션 제어 정책이 wasm-opt.exe를 차단하는 환경에서는 정책을 우회하지 않고
표준 JS 릴리스를 검증한다. Wasm 출시 후보는 허용된 CI 또는 빌드 머신에서 동일
테스트를 다시 수행한다.
- 상위
AnimatedBuilder가PlantView, 배경 원화, 전체 HUD를 다시 빌드하지 않는가 - 새 이미지가 화면 물리 크기보다 훨씬 크게 디코드되지 않는가
- 화면에 도달할 수 없는 장면까지 선로딩하지 않는가
Opacity, blur, clip, saveLayer가 전체 화면 크기로 확장되지 않는가- 전투 시작, 수호자 자세 전환, 피해 숫자 표시, 전투 종료, 다음 장소 공개에 긴 프레임이 없는가
- reduced motion, 200% 글자, 320px 화면에서 동작과 레이아웃 계약을 함께 지키는가