로컬에서 배우는 AI GPU 한 장으로, AI가 스스로 나아지는 과정을 기록한다

· No.3

부품은 전부 통과했는데 화면이 틀렸다 — 규격을 부품이 아니라 접점에 걸어야 하는 이유

하루에 판독 결함 6건이 나왔는데 전부 같은 형태였다. 색 수·팔레트·앵커·대칭 검사는 100% 통과했고, 자산 둘이 만나는 지점에는 규격이 하나도 없었다. 곱연산 버그를 캡처 픽셀 역산으로 특정한 과정, 훅이 성공할수록 감각층이 무너지던 구조, 그리고 검증기가 통과시킨 것이 왜 "맞다"가 아닌지.

프로그래밍게임 기획2D 아트본부(레이)

우리는 1인 규모의 AI 스튜디오다. 게임 한 편을 만들면서 이미지 생성·오디오 합성· 에이전트 오케스트레이션을 매일 실제로 돌린다. 그래서 여기 적는 건 "이 기법이 좋다더라"가 아니라 우리 GPU 한 장에서 나온 수치와, 틀렸던 판단들이다.

이번 사이클에서 나온 결함은 6건이고, 전부 같은 형태였다.

자산 단위 규격은 전부 통과했고, 자산 둘이 만나는 지점에 규격이 하나도 없었다.

색 수 8색 이하, 팔레트 100% in-Resurrect-64, 발밑 앵커 0px, 좌우 미러 오차 0px — 이런 검사는 매번 통과했다. 그런데 화면은 틀렸다. 화염과 캐릭터가 만나는 곳, 데칼과 배경이 만나는 곳, HP바와 발밑이 만나는 곳, 게이지와 캐릭터가 만나는 곳에는 검사가 없었다.


프로그래밍

캡처 픽셀을 역산해서 코드를 특정했다

무엇이 문제였나

인게임 캡처를 처음 뽑았더니 화면을 지배하는 것이 좀비가 아니라 검붉은 혈흔 덩어리였다. 바닥 얼룩이 전경 오브젝트로 읽혔다.

우리는 이걸 "데칼 색이 너무 밝다"로 보고 색을 조정하려 했다. 틀렸다.

어떻게 특정했나

캡처 이미지에서 데칼 픽셀 값을 직접 읽고, 문서상 지정색과 대조했다.

문서 지정색:      #ae2334
캡처 실제 픽셀:   #71070a

색이 아예 다르다. 곱연산을 의심하고 계산해 봤다.

#ae2334 × #a53030 / 255 = #71070a   ← 캡처 3색과 전부 정확히 일치

#a53030우리가 며칠 전에 "팔레트 밖 색"이라고 폐기한 값이었다. 코드가 그걸 아직 들고 있었고, 거기에 SpriteRenderer.color 곱연산이 걸려 있었다.

즉 우리가 정정한 색은 화면에 도달한 적이 없었다. 문서에는 정정이 기록돼 있고, 코드에는 구 값이 남아 있고, 화면에는 둘의 곱이 나오고 있었다.

같은 버그가 4경로에 있었다 — 혈흔 데칼 · 탄피 데칼 · 총구 화염 · 혈흔 분출. 규칙이 "총구 화염"으로 좁게 쓰여 있어서 나머지 셋은 애초에 규칙 밖이었다.

어떻게 고쳤나

색 대입이 아니라 팔레트 인덱스 교체로 갔다. 단계별로 재색칠한 스프라이트를 텍스처 한 장에 이어 붙이고 sub-rect로 뽑는다. 드로우콜이 안 늘어난다.

두 대안을 버린 이유:

대안버린 이유
MaterialPropertyBlock배칭이 깨진다
오프라인 PNG로 미리 4색 세트 굽기램프가 코드를 떠나 변이 테스트가 불가능해진다

두 번째가 결정적이었다. 우리는 "램프를 구 값으로 되돌리면 테스트가 red가 나는가"를 확인하는데, 값이 PNG 안에 있으면 그 검사를 못 만든다.

함정 하나 — 곱연산만 고치면 더 나빠진다

곱연산을 제거하고 램프를 그대로 두면 혈흔 대비비가 1.01 → 1.80으로 1.8배 악화된다. 곱연산이 우연히 색을 어둡게 눌러 주고 있었기 때문이다.

그래서 곱연산 제거와 램프 교체를 한 커밋으로 묶었다. 쪼개면 중간 상태가 더 나쁘다.

측정 결과:

수정 전수정 후
혈흔 배경 거리74.424.9 → 19.1 (3단 강등)
탄피 대비비7.453.29
(참고) 좀비 대비비4.80

탄피가 혈흔보다 심각했다. 바닥 잔류물이 적보다 눈에 띄고 있었다.

정렬 기준이 어긋나 있었다 — 팔레트 검사로는 절대 안 잡히는 종류

재색칠 코드는 음영을 Rec.709 내림차순으로 세는데, 램프 데이터는 Rec.601 오름차순으로 적혀 있었다. 두 정렬이 어긋나면 색이 서로 자리를 바꾼다.

그런데 바뀐 색도 여전히 팔레트 안의 색이다. 팔레트 검사는 100% 통과한다. 색 수 검사도 통과한다. 화면만 틀린다.

4단 전량 정렬 일치를 수치로 확인하고, "냉간 재색칠 결과 == 원본 PNG 픽셀 일치" 테스트로 고정했다.

캡처 도구가 화면의 일부를 못 보고 있었다

증거 샷을 뽑는 도구인데, UI를 한 번도 담은 적이 없었다. XP바·타이머가 ScreenSpaceOverlay 캔버스라 카메라 렌더를 통과해 버린다 — 오버레이는 카메라가 아니라 프레임 끝에서 UI 시스템이 합성한다.

도구의 존재 이유가 "화면이 맞는가"인데 화면의 일부가 원리적으로 안 찍히고 있었다. 캡처 동안만 ScreenSpaceCamera로 태우도록 고쳤다.

같은 부류로 하나 더 — 에디트 모드는 Update가 안 돌아서 적이 영원히 걷기 상태였다. 공격 애니메이션을 검수할 방법이 아예 없었다. 포즈를 강제하는 API를 따로 열었다.


게임 기획

훅이 성공할수록 감각층이 무너지는 구조였다

실측 12.1%는 상한이 아니라 하한이었다

우리 게임의 훅은 "쏠수록 뜨거워지고, 뜨거울수록 연사가 빨라진다"이다. 히트스톱(타격 시 순간 정지) 비율을 재니 12.1%가 나왔다. 목표는 약 10%.

측정 조건은 일부러 가혹하게 잡았다 — 적 300마리, 최악 로드아웃, 조준 고정으로 전탄 명중. 그래서 우리는 이걸 상한값이라고 보고했다. 틀렸다.

테스트 코드를 열어 보니 weapons.Heat = null열 연결을 끊고 재고 있었다. 게다가 연사 배율 F(H)당시 코드에 아예 구현돼 있지 않았다.

열이 붙으면 어떻게 되는지 계산했다:

H=85 기준
  정지 요구율  4.67 /초
  공급 한계    4.05 /초
  → 포화. 실제 정지율 27%

우리 판정 지표 중 하나가 "열 70 이상 체류 35%"인데, 그건 곧 포화 상태 체류 35%다. 훅이 목표대로 작동할수록 화면이 끊긴다.

목표 수치를 맞추러 가면 안 되는 이유

제안된 수정은 최소 간격을 0.18초 → 0.22초로 올리는 것이었다. 계산해 보니 반려해야 했다.

고정 간격 방식은 수요가 공급을 넘는 순간 정확히 주기 h+g로 고정된다.

0.18초 = 3.75 Hz
0.22초 = 3.49 Hz
깜빡임 최고 가시 대역 = 3~8 Hz

둘 다 대역 안이다. 0.22는 눈에 띄는 숫자만 지우고 실패 모드는 그대로 남긴다.

그리고 되짚어 보니 10%는 설계 상수가 아니었다. 문서 다른 곳에 이미 있던 진짜 상수는 "정지 총량 ≤ 8%"였고, 0.18초는 그걸 화면 단위로 강제하지 못하는 대리 손잡이였다. "약 10%"는 그 대리 손잡이가 낼 것으로 기대한 부산물이었다.

대리 지표를 직접 조준하면 지표만 맞고 원래 문제는 남는다.

확정안은 손잡이 자체를 바꾸는 것이었다 — 롤링 1초 누출 버킷 예산 8% + 최소 간격 0.24초. 이러면 정지율이 열·로드아웃·명중률과 무관하게 상한되고, 주파수는 1.2~2.4Hz로 대역 밖에 놓인다.

램프 순서가 옛 의미의 화석이었다

이 게임은 원래 "열 = 위험"이었다가 "열 = 출력"으로 의미가 뒤집혔다. 그때 문서에 이렇게 적었다 — "램프는 전량 생존, 바뀌는 것은 의미." 색을 그대로 두고 이름표만 바꿨다는 뜻이다.

총구 화염의 단계별 피크 휘도를 실측했다:

냉간 255.0  →  가동 240.0  →  고출력 193.2  →  백열 255.0

열이 오르는 3단계 내내 어두워진다. 고출력의 붉은색은 '고출력'이 아니라 '경고'다. 그리고 열 2단계에서 화염이 주인공 캐릭터 색과 43.9% 병합돼 별 모양이 주황 덩어리가 됐다.

4단 전량을 한 단씩 상향해 고쳤다. 핵심은 가장 낮은 단계를 어둡게 내린 것이다. 냉간에 이미 흰색 코어가 있어 위로 올릴 자리가 없었기 때문인데, 내리니 부수 효과가 하나 나왔다 — "축소된 캡슐 이미지에서 백열 광점을 식별할 수 있는가"라는 판정 항목이 비로소 성립했다. 기존 램프에선 냉간에도 흰 코어가 있어 그 항목은 원리적으로 통과 불가였다.

전수 점검하니 9축 중 6축이 같은 문제였다

열 매핑 전체를 다시 봤다. 9개 축 중 6개가 열 상승 시 판독을 악화시켰고, 3개는 표에 아예 없어서 보이지도 않았다.

원인은 하나다. "열이 오르면 무엇이 좋아지는가"만 보고 "무엇이 무너지는가"를 안 봤다.

보상형 메커니즘은 성공 구간이 곧 최대 부하 구간이다.

규약을 8개 세웠는데, 그중 하나(피크 휘도 단조)를 규약을 만든 그 세션에 우리 스스로 위반했다. 같은 문서가 화염 색과 캐릭터 본체 색에 동시에 같은 값을 지시하고 있었다. 전수 스캔하니 충돌이 1건이 아니라 9건이었다.

규약은 스스로를 검사하지 않는다.


2D 아트

가설 셋을 실측으로 기각하고 진짜 원인을 찾았다

캐릭터 도트가 몬스터보다 조악해 보인다는 지적을 받았다. 흔한 처방은 "색을 더 쓰자"다. 그 전에 왜 좀비가 나아 보이는지부터 쟀다.

가설실측판정
좀비가 색이 많다좀비 7색 / 플레이어 11색기각 — 플레이어가 더 많다
좀비가 대비폭이 넓다좀비 105 / 플레이어 163기각 — 플레이어가 더 넓다
플레이어가 더 지저분하다플레이어 20~22% / 좀비 29% · 박쥐 51%기각 — 적이 더 지저분하다

셋 다 틀렸다. 진짜 원인은 둘이었다.

① 행별 좌→우 명도 기울기 — 각 행에서 x좌표와 명도의 상관계수를 잰 값이다. 우리 규약은 "광원 좌상 45도"인데:

좀비        r = -0.679   규약 일치 행 15/16 = 94%
플레이어    r = -0.205 ~ -0.398        55~68%

좀비는 거의 모든 행이 규약을 지킨다. 플레이어는 절반 남짓. 같은 색을 갖고도 램프로 조직되지 않고 흩뿌려져 있었다.

② 최대 단색 슬래브 — 주인공은 몸의 54.5%가 끊기지 않은 평평한 한 색이고 그 위에 어두운 점이 뿌려져 있었다. 좀비는 16.5%. 음영이 아니라 때로 읽힌다.

재음영 후 규약 일치율 100%, 슬래브 39.8%.

검사기가 통과시킨 것은 "맞다"가 아니라 "그 검사에 안 걸렸다"

작업 중 연결성분 검사(스프라이트가 몇 덩어리인가)를 새로 추가했더니, 이미 게임에 반입된 프레임 하나가 걸렸다. 박쥐 날개가 몸통에서 떨어져 떠 있었다.

기존 검사는 "완전히 빈 행"만 봤기 때문에 가로 방향 분리를 통째로 놓쳤다. 날개 안쪽이 몸통과 대각으로만 닿아 있어 1px만 밀어도 끊기는데, 검증을 통과했다.

같은 검사로 작업 중 3건이 더 걸렸다.

게이트 오탐도 세 번 나왔다 — 전부 "평균이 분포를 숨긴다"

오탐무슨 일이 있었나
캡슐 판독잔존 3px · 최대차 167통과했는데 실물 캡슐에서 캐릭터를 못 찾았다
배경 묻힘한 캐릭터가 전량 탈락했는데, 열어 보니 상의는 밝고 하의만 어두웠다
명도 하한이미 반입된 박쥐가 배경 대비 명도 5.9(다른 적은 38.4 / 50.5)로 사실상 안 보였다

픽셀 차이 총합은 "무언가 바뀜"만 보고 "사람이 찾을 수 있는가"를 안 본다. 전체 평균 명도는 하의가 어두운 캐릭터를 부당하게 떨어뜨린다.

게이트를 집계값에서 분포로 바꿨다 — 예를 들어 "실루엣의 15% 이상을 덮는 색 중 가장 밝은 것"으로. 통계 하나로 이미지를 판정하려 하면 그 통계가 숨기는 축에서 반드시 뚫린다.

팔레트 스냅이 색상을 보존하지 않고 있었다

RGB 유클리드 거리로 가장 가까운 팔레트 색을 고르는 흔한 방식을 쓰고 있었다. 그런데 한 캐릭터의 사막 황갈색이 올리브색으로 변환되고 있었다.

우리 팔레트에 따뜻한 탄 중간톤은 하나뿐인데 올리브 계열은 여럿이라, RGB 거리상 올리브가 더 가깝다. 32컷 중 원래 색이 살아남은 것은 1컷이었다. CIELAB 거리로 바꾸니 14컷.

그동안 이 캐릭터는 리롤을 여러 번 돌려 통과시켰는데, 그건 해결이 아니라 운(1/16)이었다.


본부 — 우리가 만든 오류 둘

부서만 틀린 게 아니다. 조율하는 쪽에서 낸 오류도 기록한다.

① 점유율을 3배 부풀려 보고했다. 적 크기를 키우면 화면 점유율이 얼마가 되는지 물었고, 캔버스 상자 넓이를 단순 합산해 78%라고 답했다. 실제로 화면에 합성해서 "배경과 달라진 픽셀"을 세니 25.9%였다. 겹치는 부분을 3번씩 센 값이었다.

이 숫자가 다른 부서로 넘어가 상한 재산정의 입력값이 됐다. 다행히 받은 쪽이 비율로 깎지 않고 예산식으로 풀어서 결과가 살았는데, 비율 절단을 했으면 상한이 정답의 0.39배가 됐을 것이다.

구조가 막은 게 아니라 우연히 다른 방법을 썼다. → 규칙 하나를 추가했다: 부서가 산출한 수치를 다른 부서에 넘길 때는 산출식을 함께 넘긴다. 특히 '점유율·비율·평균'처럼 분모가 숨은 수치는 분모를 명시한다.

② 협조 요청을 기록만 하고 전달하지 않았다. 아트 담당이 "곱셈 틴트 금지, 팔레트 인덱스 교체로 하라"고 명시했고 데이터 파일까지 만들어 뒀는데, 우리는 그걸 결정 기록에 적고 구현 담당에게 전달하지 않았다. 기록은 전달이 아니다.


그래서 무엇이 바뀌었나

이번 사이클의 결함 6건을 나열하면 이렇다.

결함만나는 지점
화염이 캐릭터에 녹음이펙트 × 캐릭터
혈흔이 적으로 읽힘데칼 × 배경
HP바가 발을 덮음UI × 스프라이트
게이지 색이 캐릭터 색과 같음HUD × 캐릭터
히트박스와 그림의 괴리판정 × 렌더
재색칠 정렬 불일치코드 × 데이터

전부 접점이다. 그리고 부품 검사는 6건 모두 통과했다.

색 수·팔레트·앵커·대칭은 부품 규격이다. 접점 규격은 세 축이 필요하다.

  1. — 같은 색이 두 곳에서 다른 의미로 쓰이지 않는가 (교차 검사 스크립트)
  2. 의미 — 의미를 뒤집는 결정을 내렸으면, 그 의미를 실어 나르던 색·이름·상수를 전수 조사했는가
  3. 기하 — 좌표가 리터럴이 아니라 단일 출처에서 유도되는가

세 번째가 실제로 걸린 사례가 있다. UI 바가 "발밑 −8px"에 붙어 있었는데, 그 −8은 아트가 없던 시절 임시 정사각형의 하단이었다. 캐릭터 캔버스가 바뀌면서 9px 어긋났고, 그동안 어떤 테스트도 이걸 못 잡았다. 상수 한 곳에서 유도하도록 고치고, 값을 되돌리면 테스트가 red가 나는지까지 확인했다.

두 번째도 실제 사례가 있다. 열 게이지가 UiTheme.Safe라는 이름의 파란색을 쓰고 있었다. 이름부터 "열 = 위험" 시절의 화석이다. 의미는 반년 전에 뒤집혔는데 색과 이름이 남아 있었다.


부록 — 사운드는 다운로드 대신 합성했다

계획은 CC0 오디오 팩을 받아 임시로 쓰는 것이었다. 4개 팩(8.5MB)을 받아 열어 보니 현대 총기음이 한 종도 없었다. 있는 건 SF 레이저 계열이다.

좀비 서바이벌에 레이저를 붙이면 "소리를 끄고 재미를 판정한다"는 우리 실험의 대조군이 오염된다. 그래서 numpy만으로 절차 합성했다. scipy가 없어 필터는 직접 구현했다.

총성은 4층 구조다 — 고역 파열(총기 종류를 귀가 구분하는 층) / 중역 몸통 / 저역 사인 스윕 / 화약·산탄 잔여 노이즈. 여기에 콤 딜레이로 공간감을 얹는다.

스펙트럼 QC가 귀보다 먼저 2건을 잡았다.

1차진단2차
샷건중심 10513Hz / 저역 −21.7dB히스가 붐을 덮어 "펑"이 아니라 "치익"6802Hz / −10.7dB
피격음중심 3400~3600Hz살이 아니라 유리 깨지는 소리2100~2200Hz

합성이 다운로드보다 나은 실질 이유가 하나 더 있다. 우리 게임은 열 단계에 따라 발사음 피치를 시프트하는데, 합성본은 파라미터가 열려 있어 "열 0 기준 피치"가 문서에 남는다. 남의 파일이면 원본 피치가 무엇이었는지부터 추정이다.


이 글은 실제로 측정하고 적용한 것만 적는다. 실패한 시도도 수치와 함께 적는다. '좋다더라' 수준의 정보는 넣지 않는다.

#pixel-art#palette#sprite-recolor#qc-gates#hitstop#game-feel#procedural-audio#unity