캐릭터 초상화 자동 촬영기 - 번쩍번쩍 지옥
시작은 단순했습니다
3D 전투 씬에 캐릭터 카드 UI를 넣고 싶었습니다. 이름, 레벨, 등급, 스킬, 장비 — 그리고 가장 중요한 건 3D 캐릭터 초상화였습니다. RPG 게임에서 흔히 보는 그 느낌이요. 캐릭터를 오프스크린 씬에서 렌더링하고, Tools.CreateScreenshotUsingRenderTargetAsync()로 캡처해서 카드에 붙이면 끝. 간단하죠?
아뇨. 전혀 간단하지 않았습니다.
Phase 1~3: 순조로운 출발 (이때가 행복했다)
설계 자체는 체계적으로 진행했습니다. 데이터 모델 확장, 초상화 렌더 시스템, 카드 UI 컴포넌트를 세 개의 에이전트로 병렬 개발했고, 통합과 검수까지 깔끔하게 마무리했습니다.
// CharacterPortrait.ts — 오프스크린 씬에서 캐릭터 모델+장비를 렌더
// 128x160 크기로 캡처, unitType별 메모리 캐싱
Tools.CreateScreenshotUsingRenderTargetAsync(
engine, camera, renderTarget
);
검수에서 dead code, AnimationManager 메모리 누수, 불필요한 이중 조회까지 잡아냈습니다. 빌드도 깨끗하게 통과. "이거 생각보다 쉬운데?" 싶었던 그 순간이 함정이었습니다.
클로즈업 과다: 코 구멍이 보입니다
브라우저에서 결과를 확인한 순간, 캐릭터의 얼굴이 화면을 가득 채우고 있었습니다. 초상화가 아니라 증명사진 확대본이었습니다. 코 구멍 디테일까지 선명하게 보이는 수준이요.
カメラ 파라미터를 조정했습니다:
// Before: 얼굴 확대경
FRAMING_TARGET_Y_RATIO: 0.7
FRAMING_RADIUS_RATIO: 0.8
// After: 상반신이 보이게
FRAMING_TARGET_Y_RATIO: 0.55
FRAMING_RADIUS_RATIO: 1.8 // 2배 이상 줌아웃
좋아, 이제 좀 나아졌겠지? 새로고침.
뒷모습: "무슨 개소리야"
줌아웃은 됐는데, 이번엔 캐릭터의 뒷모습이 찍혔습니다. 초상화에 뒷통수라니. 카메라 각도를 돌려야 합니다.
180도. 아닙니다. 270도. 이것도 아닙니다. 45도. 비스듬하게 보이긴 하는데...
"무슨 개소리야 애초에 위치가 안 먹고 있는 거 아냐?"
맞았습니다. 카메라 ALPHA 값을 바꿔도 렌더링 결과에 반영이 안 되고 있었습니다. 오프스크린 씬에서 ArcRotateCamera의 알파 값이 제대로 적용되지 않는 문제. 삽질의 시작이었습니다.
번쩍번쩍 지옥
그 다음 시도가 진짜 지옥이었습니다. 메인 카메라 앞에서 캐릭터를 스폰하고 캡처하는 방식으로 바꿔봤더니 — 화면이 번쩍번쩍 깜빡이기 시작했습니다. 캐릭터가 메인 뷰포트에 순간적으로 나타났다 사라지면서 플래시를 터뜨리는 겁니다.
다른 카메라를 사용해봤습니다. 여전히 번쩍.
"지금도 번쩍거려. 아 화내기도 힘드네."
까만 화면으로 차폐하는 방법을 시도했습니다. 캡처 직전에 검은 오버레이를 씌우고, 캡처 후 제거하는 방식.
"더 별로임"
번쩍임은 줄었지만, 검은 화면이 깜빡이는 건 오히려 더 거슬렸습니다. 사용자 경험이 지옥에서 다른 지옥으로 이동한 셈이었습니다.
추가 버그: 설상가상
이 와중에 발견된 추가 문제들:
- 로딩 무한루프: 초상화 렌더링이 끝나지 않아 전투 씬 진입이 블로킹됨
- 동일 이미지 3장: 캐싱 키가 unitType 기준이라 같은 타입의 유닛 3명이 전부 동일한 초상화를 공유
- 이름/레벨 텍스트 겹침:
horizontalAlignment누락으로 UI 요소가 서로 위에 쌓임
// 이름과 레벨이 같은 위치에 렌더링되는 참사
// horizontalAlignment가 빠져있어서 둘 다 기본값(center)으로...
const nameText = new TextBlock();
nameText.text = "아서 Lv.5";
// horizontalAlignment = ??? <-- 이게 없었음
결국 선택한 해결책: 정적 파일
실시간 렌더링을 포기하고 정적 이미지 파일 기반으로 전환했습니다. 때로는 가장 단순한 방법이 최선입니다.
// CharacterPortrait.ts — 파일 기반으로 전환
static getPath(unitType: string): string {
return `/textures/portraits/${unitType}.png`;
}
// dev 도구: 모든 초상화를 렌더 후 PNG로 다운로드
static async generateAllAndDownload(engine: Engine): Promise<void> {
// 오프스크린 씬 생성 → 렌더 → 다운로드 → 즉시 정리
}
BattleUIManager에서 비동기 렌더링 코드를 전부 제거하고, 단순히 파일 경로만 전달하도록 변경했습니다:
// Before: 비동기 렌더링 지옥
const portraitUrl = await CharacterPortrait.capture(engine, unitType);
// After: 평화로운 정적 경로
const portraitUrl = CharacterPortrait.getPath(unit.unitType);
카메라 방향도 이 과정에서 수정했습니다. ALPHA 값을 -PI/6에서 +PI/6으로 바꿔 캐릭터가 화면 오른쪽을 바라보도록. 이미지 파일이 없을 때를 대비한 placeholder 처리도 추가했습니다.
카드 레이아웃 완성
초상화 문제를 해결한 후, 카드 레이아웃을 다듬었습니다:
┌───────────────────────────────────┐
│ ┌────────┐ ┌───────┐ ┌───────┐ │
│ │Portrait│ │⚔한손검│ │🛡방패 │ │
│ │ │ └───────┘ └───────┘ │
│ │아서 Lv5│ │
│ │★★ Epic │ [Skill1] [Skill2] [Skill3]│
│ └────────┘ │
└───────────────────────────────────┘
초상화 위에 이름/레벨/등급을 오버레이하고, 우측에 장비와 스킬 슬롯을 배치. 카드 클릭 시 유닛 선택 기능까지 연결했습니다. selectUnit이 private이어서 public으로 변경하고 인덱스 기반 선택 메서드를 추가하는 등 소소한 작업도 있었지만, 초상화 지옥에 비하면 평화로웠습니다.
교훈
1. 실시간 렌더링이 항상 답은 아닙니다
"동적으로 생성하면 멋있겠지"라는 생각이 이틀의 삽질로 이어졌습니다. UI 초상화처럼 자주 바뀌지 않는 에셋은 빌드 타임에 미리 생성하고 정적 파일로 서빙하는 게 훨씬 안정적입니다.
2. 오프스크린 렌더링은 생각보다 까다롭습니다
Babylon.js의 RenderTargetTexture는 강력하지만, 메인 렌더 루프와의 동기화, 카메라 파라미터 적용 타이밍, 리소스 정리 등 함정이 많습니다. 특히 메인 카메라 앞에 오브젝트를 스폰하는 방식은 절대 하지 마세요. 번쩍번쩍 지옥이 기다립니다.
3. 감정 관리도 개발의 일부입니다
"화내기도 힘드네"라는 말이 나올 정도로 지친 상태에서는 판단력이 흐려집니다. 까만 화면 차폐 같은 땜질식 해결책에 시간을 쓰는 것보다, 한 발 물러서서 접근 방식 자체를 재고하는 게 더 빠른 길입니다.
4. 검수 단계를 절대 건너뛰지 마세요
병렬 개발 후 통합-검수 단계에서 dead code, 메모리 누수, 불필요한 이중 조회 3건을 잡았습니다. 빌드가 통과한다고 끝이 아닙니다. 코드 품질은 타입 체커가 보장해주지 않습니다.