
AI에게 품질을 채점하게 하는 것을 그만둔 이야기 — 브라우저 FPS를 26,000행 작성하며 겪은 「측정의 4가지 함정」
요약
AI 에이전트에게 품질 채점(Scoring)을 맡길 때 발생하는 측정 오류와 함정을 다룹니다. 수치 기반의 채점은 기준점이 변할 수 있어 시계열 비교가 불가능하므로, AI는 결함 발견과 제안에 집중하고 실제 측정은 자동화된 시스템에 맡겨야 함을 강조합니다.
핵심 포인트
- AI에게 채점권을 주면 기준이 변해 시계열 비교가 불가능해짐
- AI는 비평(제안)을 하고, 자동화된 시스템이 측정(심판)해야 함
- 프롬프트에 포함된 수치는 이미 오래된 데이터일 수 있어 주의 필요
- 측정의 규율을 확립하는 것이 결과물 자체만큼 중요함
株式会社Good Lab에서 엔지니어를 하고 있는 코타로(コータロー)입니다.
매일 Java, SQL, Git 등의 기술 정보와 신입 엔지니어를 위한 학습 노하우, AI 활용에 관한 정보를 발신하고 있습니다.
Good Lab에 대해 궁금하신 분은 코퍼레이트 사이트도 꼭 확인해 주세요.
▶코퍼레이트 사이트
AI에게 품질을 「채점」하게 하면, 척도가 매번 바뀌기 때문에 시계열 비교가 성립하지 않는다.
실체가 개선되고 있음에도 스코어가 떨어진다. 그리고 그 상태는 단순히 도움이 되지 않는 것이 아니라 무가치 이하이다. 의뢰인에게 「악화되고 있다」는 잘못된 시그널을 보내, 존재하지 않는 문제를 쫓게 만들기 때문이다.
비평을 시킨다면 「결함의 발견」까지만. 채점권은 자동 측정에 넘겨야 한다.
비평은 제안하고, 측정이 심판한다.
이하는 이 결론에 도달하기까지 저와 Codex, 그리고 비평 에이전트가 모두 빠졌던 함정과 그 이후에 나타난 실제 버그의 기록이다. 수치는 모두 실측값이며, 부풀리지 않았다.
Three.js (브라우저 / WebGL2)로 1인칭 슈터를 만들고 있었다. 제약이 하나 있다.
외부 에셋 금지. 텍스처는 모두 GPU 상에서의 절차적 생성 (Procedural Generation), 지오메트리(Geometry)는 모두 코드 생성. 에셋 총량 0MB.
| 구현 | Three.js ^0.185.1 / Vite ^8.1.5 / Node 25.6.1 |
| 규모 | 99 커밋 / src 하위 60개 파일 / 25,997행 |
| 성능 | 1280×720 에서 60fps (draw call 400 / 삼각형 602,074) |
| 에셋 | 0MB (외부 파일 로드 제로) |
시작은 「최신 Call of Duty 급을 만들어라, AAA급으로 보일 때까지 멈추지 마라」라는 무리한 요구였다.
미리 말해두자면, CoD를 이기지는 못했다. 원리적으로 불가능하다. 스키닝(Skinning)도 모션 캡처(Motion Capture)도 포토그래메트리(Photogrammetry)도 GI(Global Illumination)도 없으며, 에셋은 0MB 대 수백 GB이다. 이 점은 처음부터 요구자에게 그렇게 전달했다. 이 기사에서 달성했다고 말할 수 있는 것은 「브라우저 FPS로서 최고 수준을 목표로 하는 과정에서, 측정의 규율을 확립한 것」뿐이다.
그리고 아이러니하게도, 가치가 있었던 것은 그림 그 자체보다 이 규율 쪽이었다.
모두 실제로 일어났고, 판단을 왜곡했다. 공통된 형태는 하나다.
「수치는 나왔지만, 그것이 자신의 변경에 의한 효과가 아니다」
Codex에게 구현을 위임하고 있었을 때, 3번 중 2번 이렇게 보고해 왔다.
「2.66 → 3.70으로 개선되었습니다」
하지만 당시의 HEAD는 이미 3.71이었다. 내가 병행해서 다른 수정을 넣고 있었기 때문에, Codex가 「작업 전의 값」으로 참조한 수치가 착수 시점에 이미 오래된 것이었다.
즉 Codex는 「자신이 2.66을 3.70으로 만들었다」고 믿고 있었지만, 실제로 한 일은 3.71을 3.70으로 미세하게 감소시킨 것이었다. 개선 보고의 내용은 실질적으로 아무것도 하지 않았을 뿐만 아니라 오차로 인해 약간 악화된 것이다.
이것은 에이전트 측의 태만이 아니다. 프롬프트(Prompt)에 적혀 있는 수치를 믿는 것은 합리적인 행동이다. 잘못된 것은 오래될 수 있는 수치를 프롬프트에 적은 나 자신이다.
프롬프트 서두에 이것을 반드시 적도록 했다.
【측정 규칙】
이 프롬프트 내에 적힌 수치를 베이스라인(Baseline)으로 삼지 마라.
그것들은 작성 시점의 값이며, 이미 오래되었을 가능성이 있다.
...
3번째 위임에서는 이 오류가 사라졌다.
이것은 나 자신이 빠진 함정이다.
화면의 고정 좌표 (700, 430, 110x50)
의 직사각형 영역을 잘라내어 「개선되었는가」를 측정하고 있었다. 어떤 변경 후에 다음과 같이 판정했다.
3.71 → 2.64로 악화. 이 방향은 실패.
그런데 모델의 형상이 바뀌면서 구도가 약간 어긋났다. 같은 직사각형이 더 이상 같은 물체를 가리키고 있지 않았다. 대상 물체는 오른쪽 아래로 밀려났고, 직사각형 안의 내용은 절반이 배경 벽이 되어 있었다.
대상 메쉬(Mesh)만 비표시한 차분 마스크(Difference Mask, 후술)로 다시 측정하자 결과는 반대였다.
| 측정 방식 | before | after | 판정 |
|---|---|---|---|
| 고정 직사각형 (700,430,110x50) | 3.71 | 2.64 | 악화 (오류) |
| 오브젝트 마스크 | 4.76 | 5.50 | 개선 (정상) |
위험했던 것은, 이 오판을 믿고 올바른 방향의 변경을 롤백(Rollback)할 뻔했다는 것이다. 수치가 나와 있으면 사람은 의심하지 않는다.
고정된 사각형으로 A/B 테스트를 하지 않는다. 물체를 숨긴 차분(Difference)으로 마스크를 만들고, 그 물체의 픽셀만 측정한다.
"지면에 볼거리가 있는가"를 국소 표준편차 (5×5 윈도우의 SD)로 측정하고 있었다. 어느 날, 에이전트가 "목표를 달성했다"라고 보고해 왔다.
지면의 국소 SD 평균 5.90 (목표 4.5 이상) → 달성
직접 분포를 다시 뽑아보니 다음과 같았다.
평균 5.90
중앙값 3.12
크게 괴리되어 있다. 원인은 명확했다. 지면에 떨어져 있는 **소수의 파편의 딱딱한 윤곽(Edge)**이 평균을 끌어올렸을 뿐이었다. 면의 대부분은 여전히 평탄한 상태였으며, "지면이 지루하다"라는 원래의 문제는 단 1mm도 해결되지 않았다.
이는 합성 데이터(Synthetic Data)로 간단히 재현할 수 있다. 다음 스크립트는 거의 평탄한 면에 딱딱한 에지(Edge)를 6개만 그은 것이다 (node demo_sd.mjs로 실행 가능).
// demo_sd.mjs — 국소 SD(5x5)의 평균과 중앙값의 괴리를 재현함
function localSd(L, w, h) {
const v = [];
...
실행 결과.
{ mean: 8.89, median: 1.73, n: 15876 }
면적의 고작 2.3%에 불과한 에지로 인해, 평균이 중앙값보다 5배 이상 높게 둔갑한다. 평균 8.89라면 "정보량이 풍부한 면"처럼 보이지만, 실제로는 중앙값 1.73의 밋밋한 평면이다.
국소 SD는 반드시 중앙값으로 판정한다. 평가표에도 그렇게 적어두고 고정했다.
여러 에이전트에게 병렬 작업을 시키고 있었기에, 빌드 출력 디렉터리(Output Directory)를 나누어 두었다.
npx vite build --outDir dist-me # 나
npx vite build --outDir dist-agent-a # 에이전트 A
이것으로 독립되어 있다고 생각했다. 아니었다.
vite build가 읽는 것은, --outDir이 무엇이든 간에 **공유된 src/**이다. 다른 담당자가 편집 중인 파일이 그대로 내 빌드에 포함된다.
실제 피해는 이렇게 나타났다. 쉐이더(Shader)의 파라미터 단 한 줄만 바꾸고 A/B 테스트를 찍었는데, 삼각형(Triangle) 수가 5,696개 줄어들어 있었다. 쉐이더는 삼각형을 단 한 장도 늘리거나 줄이지 않는다. 차이의 정체는 담당 외의 파일(다른 에이전트가 동시에 편집하던 LOD 주변)의 혼입이었다.
이때 나는 순간 "쉐이더 변경이 정점 처리(Vertex Processing)에 영향을 준 건가?"라고 생각했다. 존재하지 않는 문제를 쫓기 시작한 것이다.
수치를 보고할 때만은 다음 절차를 밟는다. 겉모습만 확인하는 것이라면 공유된 트리(Tree) 상태여도 상관없다.
# 1. 자신의 변경 사항만 패치(Patch)로 추출
git diff src/render/PostFX.js > /tmp/mine.patch
# 2. HEAD만 포함된, 아무도 건드리지 않은 트리를 생성
...
포인트는 세 가지다.
- 포트(Port)도 전용으로 사용한다 (
--strictPort를 붙여서, 타인의 서버에 연결된 상태임을 인지하지 못하는 상황을 방지한다) node_modules는 심볼릭 링크(Symbolic Link)로 처리한다 (재설치는 시간 낭비다)- Before와 After의 차분이 패치 한 장 분량임을 보장한다
병렬 에이전트 운용에서 수치를 다룬다면, 이는 필수라고 생각한다.
비평 에이전트(AAA 타이틀의 아트 디렉터 역할)에게 라운드마다 그림을 채점하게 했다. 점수는 계속 떨어졌다.
라운드 5: 47점
라운드 6: 43점
라운드 7: 36점
보통대로 읽으면 "작업할수록 악화되고 있다"이다. 하지만 실체는 매 라운드 개선되고 있었다. 원인은 단순했다. 비평의 엄격함이 매번 높아지고 있었던 것이다. 같은 그림을 더 엄격한 잣대로 다시 측정하고 있었을 뿐이었다.
결정적이었던 것은, 이후 라운드에서 처음으로 오브젝트 마스크(Object Mask)가 사용되면서, 그 이전의 비평들이 범했던 측정 실수가 판명된 것이다.
| 비평의 주장 | 실제 |
|---|---|
| 「적의 윤곽 대비(Contrast)는 실효 제로. SD 60」 | 직사각형에 배경을 포함하고 있었으며, SD 60은 배경의 분산이었다. 적만 마스크(Mask)하면 중앙값(Median)이 20이었다 |
| 「그 적은 약 35m」 | 씬 그래프(Scene Graph)의 좌표로 계산하면 16.6m |
| 「컨테이너의 파형판은 인쇄된 타일. 자기상관(Autocorrelation) 주기는 69px」 | 69px는 1m UV 타일의 주기. 파형판 자체는 텍스처에 구워져(Baked) 있었다 |
| (구현 측 보고) 「모든 머티리얼(Material)에 스펙큘러 AA(Specular AA)를 추가했다」 | |
MaterialLibrary.get() 내부에서만 호출되고 있었으며, 뷰 모델(View Model)에는 전혀 적용되지 않고 있었다 |
즉 47점과 36점은 서로 다른 잣대로 나온 숫자였다. 나란히 놓고 시계열로 비교하는 것 자체가 성립하지 않는다.
까다로운 점은 비평 그 자체가 「똑똑해지고 있다」는 점이다. 라운드 7의 비평은 라운드 5의 비평보다 확실히 옳다. 그럼에도 불구하고, 그 두 개를 나란히 놓는 순간 거짓이 된다.
docs/SCORECARD-v1.0.md
와 tools/scorecard.mjs
를 한 쌍으로 만들고, scorecard-v1.0
이라는 git 태그를 달았다. 규칙은 세 가지만 있었다.
1. 잣대는 여기서 동결한다. 이후에는 변경하지 않는다.
2. 새로운 결함은 v1.1로서 【추가】한다. 기존 항목의 재정의는 영구히 하지 않는다.
3. 비평의 새로운 지적은 측정 가능한 검사(Inspection)로 변환되어야만 비로소 평가표에 올라간다.
3번이 핵심이며, 비평 에이전트에게는 이후 지적할 때마다 반드시 이 항목을 작성하도록 했다.
【이 지적의 합불을 어떻게 측정할 것인가】
- 측정 커맨드:
- 대상 영역을 정하는 법 (고정 직사각형은 불가):
...
이것을 작성할 수 없는 지적은 채택하지 않는다. 「왠지 모르게 저렴해 보인다」는 지적으로는 받아들이되, 평가표에는 올리지 않는다.
임계값(Threshold)의 근거를 「같은 그림 안의 다른 물체와의 비교」로 한정한 것도 의도적이다. 외부에서 가져온 절대값(「AAA 급이라면 SD 8은 필요하다」 등)은 검증할 수 없지만, 「지면의 국소 SD 중앙값 ≥ 4.5 = 같은 그림의 녹슨 컨테이너 6.9 · 벽돌 벽 6.7의 2/3」라면 그 자리에서 누구든 확인할 수 있다.
만약을 위해 적어두자면, 비평 에이전트는 남겨두었다. 이유는 단순하다. 결함 발견기로서는 극히 유능했기 때문이다. 다음 장의 실제 버그 3건은 모두 비평의 지적에서 시작되었다.
죽인 것은 채점권뿐이다. 관찰안이 아니라 판정권을 빼앗았다는 이야기다.
이것이 진짜 결말이다. 주관적인 논의를 그친 순간, 진짜 버그들이 차례차례 나타났다.
그레이드 숄더(Grade Shoulder, 하이라이트 상단을 완만하게 만드는 곡선)를 AgX 톤맵(Tone Map)의 후단에 두었었다.
// 【발췌 · 수정 전 프래그먼트 셰이더(Fragment Shader)】
vec3 shoulder(vec3 c, float k) {
vec3 t = max(c - k, vec3(0.0));
...
이 식은 「입력이 [0, ∞)로 들어온다」는 전제의 지수 커브(Exponential Curve)다. 그런데 AgX는 출력을 대체로 [0, 1]로 수렴시키므로, 입력의 최댓값이 1.0밖에 되지 않는다. 정규화(Normalization)하지 않았기 때문에, 입력 1.0에 대한 출력은
k + w * (1 - e^-1) = k + 0.632 * (1 - k)
k = 0.40이라면 0.779가 화면의 물리적인 상한이 된다.
실측해보니, 13개 구도 중 12개 구도에서 최대 휘도가 221~232에 고정되었고, 255에 도달하는 픽셀이 단 하나도 존재하지 않았다. 태양도 머즐 플래시(Muzzle Flash)도 하얀 벽도 모두 같은 값으로 뭉개졌다. 즉 「빛의 강약 서열」이 화면에서 사라져 있었다.
「왠지 모르게 칙칙한 그림」의 정체가 이것이었다. 주관적으로 논의하고 있는 한 절대 나올 수 없는 종류의 버그다.
수정은 정규화를 거친 뒤 멱 커브(Power Curve)로 교체했다.
// 【발췌 · 수정 후 프래그먼트 셰이더】 uShoulderPow는 uniform (채택값 2.7)
vec3 shoulder(vec3 c, float k) {
vec3 t = max(c - k, vec3(0.0));
...
왜 「정규화된 지수 커브」가 아니라 멱 커브인가. 정규화된 (1-e^-u)/(1-e^-1)은 반드시 직선보다 위쪽에 위치하므로, 상단을 1.0에 도달하게 만들면 중~고휘도 영역까지 함께 끌어올려 버린다 (실측: 숄더 0.40에서 화이트 클리핑 1.59%, 햇빛을 받는 파사드(Facade)의 L>225가 33.67%).
멱 커브 u^g (g>1)라면,
u = 1
가 반드시 출력 1.0 → 태양과 머즐 플래시(Muzzle Flash)는 255에 도달 -
u
가 작을수록 강하게 가라앉음 → 햇빛을 받는 벽은 어깨 바로 위 수준에 머무름
이라는 「상단부만 남기고 중~고휘도를 억제하는」 동작이 된다. 최종적으로 g = 2.7에서, 햇빛을 받는 파사드(Facade)의 L>225는 0.38%로 수렴했다.
FPS의 뷰모델(ViewModel, 손과 총)은 깊이 버퍼(Depth Buffer)를 클리어한 후 별도의 패스(Pass)로 그린다. 벽에 파묻히지 않게 하기 위해서다.
그 결과, DoF(Depth of Field, 피사체 심도)가 참조하는 깊이 텍스처(Depth Texture)에 총이 존재하지 않는다. 총의 픽셀 블러(Blur) 정도는 「총 뒤에 있는 벽의 깊이」에 의해 결정되고 있었다.
ADS(Aim Down Sights, 조준 동작)에서는 초점 거리가 원방으로 이동한다. 따라서,
화면에서 가장 날카로워야 할 조준경이, 화면에서 가장 흐릿하게 보였다.
실측(영역의 평균 기울기(Average Gradient)로 「날카로움」을 측정).
| 구분 | 수정 전 | 수정 후 |
|---|---|---|
| 무기의 날카로움 | 3.07 | — |
| 중경 건물의 날카로움 | 3.88 | — |
| 날카로움 비율 (무기/배경) | 0.93 (역전) | 1.61 → 최종 1.80 |
수정은 DoF를 단독 패스로 분리하여, 뷰모델을 그리기 전에 완료했다.
// 【발췌 · postprocessing의 EffectComposer 전제】
// DoF는 단독 패스로 만들어 뷰모델보다 앞에 둔다.
// ViewModelPass는 깊이를 클리어한 후 총을 그리기 때문에, DoF가 참조하는 깊이에 총이 없다.
...
평가표에는 「ADS 날카로움 비율 (무기/배경) ≥ 1.2」라는 게이트(Gate)를 추가했다. FPS에서 가장 날카로워야 할 대상이 화면에서 가장 날카롭다는 것을 기계적으로 보장하는 항목이다.
가장 효과가 컸던 것은 이것이다.
동일한 구도를 연속으로 두 번 촬영하여, 픽셀별 차이의 절대값을 평균하면 2.4~3.0이 나오고 있었다. 아무것도 바꾸지 않았는데도 말이다.
차분 맵(Difference Map)을 이미지로 출력해 보니 명확했다. 모든 물체의 윤곽선이 빛나고 있었다.
원인은 적응형 해상도(Adaptive Resolution)였다. FPS에 따라 내부 렌더링 해상도를 조절하는 메커니즘이 촬영 중에도 작동하고 있었다. 촬영할 때마다 내부 해상도가 달라지며, 최종 출력으로 리샘플링(Resampling)하는 과정에서 모든 물체의 윤곽선이 1px씩 어긋났다.
국소 표준편차(Local SD)와 같은 지표는 이것만으로도 2~3배씩 요동친다.
즉, 이것을 고치기 전까지 수행한 모든 A/B 비교는 신뢰할 수 없었다. 함정 2의 오판정도 이러한 비결정성(Non-determinism)이 한 원인일 가능성이 높다.
촬영 시 다음 사항을 고정했다.
| 고정 항목 | 이유 |
|---|---|
| 적응형 해상도 (스케일 1.0으로 고정) | 리샘플링 시 윤곽선이 움직임 |
| ... |
결과적으로 평균 |Δ|는 0.53~0.94로 떨어졌다 (훗날 평가표를 동결했을 때의 실측치는 구도에 따라 0.57~1.26). 0이 되지는 않는다. 파티클(Particle)은 의도적으로 확률적이기 때문이다. 그래서 평가표의 게이트는 「≤ 1.5」로 설정하여, 계통적인 비결정성을 탐지하는 용도로 정의했다.
여기에는 두 가지 교훈이 있다.
- A/B 테스트를 시작하기 전에, 먼저 「같은 것을 두 번 측정했을 때 일치하는가」를 확인하라. 이를 수행하지 않았기에 무효한 비교를 수십 번 쌓아 올렸다. 게이트를 0으로 만들지 마라.
- 의도적으로 랜덤한 요소가 있다면, 그만큼을 허용치에 포함하여 문서화하라.
비평으로부터 「ADS 시 사이트 픽처(Sight Picture)가 성립되지 않는다. 조준경의 시야 구멍(Aperture)은 등가 직경이 12.6px밖에 되지 않아 조준 대상이 보이지 않는다」라는 지적을 받았다.
이때 3가지 안(근거리 블러로 눈속임하기 / 지오메트리(Geometry) 변경하기 / 구도 변경하기)을 격리된 워크트리(Worktree)에서 독립적으로 시제품 제작하게 하고, 각각에 전담 검증자를 배치했다. 3가지 안이 모두 독립적으로 동일한 근본 원인에 도달했다.
근본 원인은 치수가 아니라 **겉보기 각도(Apparent Angle)**였다.
실제 총을 조준할 때, 사수의 눈은 리어 어파처(Rear Aperture)의 5~8cm 후방에 위치한다. 하지만 FPS의 뷰모델에서 카메라를 6cm까지 밀착시킬 수는 없다 (스톡(Stock)과 뺨 대기 동작이 화면을 가득 채우기 때문). 실측 결과 카메라에서 조준경까지의 거리는 29.5cm였다.
동일한 실측 치수로 만들면 다음과 같이 된다.
M4의 대경 어파처 φ5.08mm (0.200")
실제 사격: 5.08 / 60 = 0.08467 rad → 화면 79.4px (사이트 픽처 성립)
게임: 5.08 / 295 = 0.01722 rad → 화면 16.1px (구멍 안에서 아무것도 보이지 않음)
실사(実射)의 1/5의 시각(見かけ角)밖에 되지 않는다. 실총의 치수를 정확하게 재현했던 것이 오히려 그림이 성립되지 않는 원인이었다. "애퍼처(aperture)를 실총의 대경(大径)에 상당하게 넓혀도 해결되지 않았다"는 이유도 이것으로 설명이 가능하다.
그래서 조문(照門) 만을 시각 기준으로 역산했다.
각지름 = 5.08 / 60 = 0.08467 rad
29.5cm 면에서의 지름 = 0.08467 × 295 = 24.98mm → 반지름 12.5mm
결과적으로, 시야 구멍은 123px → 3526px가 되었다.
이 예외는 리어 애퍼처(rear aperture) 한 곳으로 한정하고, 코드에 그 취지와 계산식을 작성했다. 손, 탄창, 그립은 실측치 그대로 두는 것이 옳다. 예외를 만들 때는 "어디까지가 예외인가"를 명시하지 않으면, 다음에 이 코드를 만지는 사람(혹은 다음에 실행할 에이전트)이 이를 전체로 확대 적용해 버린다.
자동 측정에 채점권을 넘겨주면, 반드시 다음 문제가 찾아온다. 게이트(gate)를 통과하기 위한 변경이다.
평가표에 명문으로 금지 규칙을 적었다. 두 가지 사례를 들겠다.
"손의 국소 SD(Standard Deviation) 중앙값 ≥ 7.0"이라는 게이트가 있었다. 노멀 맵(normal map)의 강도(normalScale)를 스윕(sweep)하여 측정한 실측값은 다음과 같다.
| normalScale | 국소 SD 중앙값 | 고립 화소(salt-and-pepper) 노이즈 | 외관 |
|---|---|---|---|
| 0.6 | 3.23 | 0.62% | 밋밋함 |
| 2.6 | 5.53 | 1.35% | — |
| 3.6 (채택) | 6.84 | 1.64% | 피부 질감 |
| 6.0 | 9.24 | 3.48% | 철수세미 / 스펀지 |
6.0이라면 9.24로 여유 있게 합격한다. 하지만 8배로 확대하면 철수세미처럼 보였고, 검증자도 "녹"이나 "곰팡이"라고 평했다.
더 나쁜 것은 움직임이다. 손을 0.4mm 움직였을 때의 평균 |ΔL|이 2.08 → 4.18로 배증한다. 실제 플레이 시 무기 스웨이(weapon sway)에서 화면이 시머링(shimmering) 현상을 일으킨다는 뜻이다.
채택한 것은 3.6(국소 SD 6.84 = 미달)이다.
그리고 중요한 것은 여기서부터인데, 게이트의 수치는 변경하지 않았다. 대신 다음과 같이 기록하여 미달 상태로 남겨두었다.
이 게이트는 "눈에서 0.35m 떨어진 물체가, 8m 앞의 벽돌 벽(5.83)보다 낮아서는 안 된다"는 의도로 설치되었다. 실측치 6.65~6.84는 5.83을 명확히 상회하고 있으므로 의도는 충족한다. 7.0에 도달하려면 normalScale 6.0이 필요하며, 이는 그림을 망가뜨린다(위 표 참조). 따라서 미달인 채로 출하한다.
임계값(threshold)을 6.5로 낮추면 "합격"시킬 수 있었다. 하지만 하지 않았다. 한 번이라도 "도달할 수 없으니 임계값을 낮추자"라는 행동을 하면, 평가표는 측정기가 아니라 합격증 발행기가 된다.
"뷰모델(viewmodel)/배경의 휘도 중앙값 비"라는 게이트에 대해서는, 단순히 알베도(albedo)를 높이면 수치를 맞출 수 있었다. 결과는 "하얀 수지 장난감"이었고, 이는 기각되었다.
참고로 이 항목에 대해서는 추후 "구도 고유의 제약"이라는 결론을 내렸다. rooftop 구도는 배경(그늘진 옥상)의 휘도 중앙값이 74.6으로 어둡기 때문에, 비 0.85를 만족하려면 무기를 L=64까지 낮춰야 한다. 이는 뷰모델의 가독성 하한선(62) 직전이며, 과거에 "검은 종이 오리기"라고 비판받았던 영역으로 돌아가는 것이다. sun 구도에서는 동일한 변경으로 0.94 → 0.84를 달성할 수 있었으므로, 이 구도에서만 기준과 가독성이 양립할 수 없다고 적어 남겼다.
모든 항목이 "클리어 완료" 또는 "실측으로 도달 불가능함을 증명 및 문서화 완료" 중 하나가 된 시점.
도달 불가능하다면, 왜 도달할 수 없는지를 실측치를 곁들여 적어 남긴다. 이로써 비로소 "끝"이 정의될 수 있었다.
원래의 요구사항인 "CoD(Call of Duty)를 이길 때까지 멈추지 마라"는 구조적으로 정지하지 않는 종료 조건이었다. 정지 조건이 없는 루프를 돌리는 한, 아무리 개선해도 영원히 "아직 부족하다"라는 답변만 돌아올 뿐이다. AI 에이전트를 장시간 실행할 때, 이는 상상 이상으로 치명적이다.
평가표 동결 시점의 기준점은 41/41 합격. 그 후 v1.1에서 항목을 추가하였고, 위와 같은 "미달이지만 문서화 완료"를 포함한 상태로 출하 조건을 충족했다.
한때, 햇빛이 비치는 벽의 화이트아웃(whiteout)에 대해 "톤 커브(tone curve)로는 원리적으로 해결할 수 없다"고 결론지은 적이 있다.
근거는 있었다. 벽 담당자가 실측을 통해 "알베도를 0.83배로 해도 출력값은 6밖에 움직이지 않는다"고 보여주었고, 나는 거기서부터 "노출(exposure)도 톤 커브도 마찬가지일 것이다"라고 외삽(extrapolation)했다. 과거에 노출을 낮추는 안을 시도했다가 실패했던 기억도 있었다.
하지만 다음 라운드에서 비평 AI가 다음과 같이 답해왔다.
이전 라운드의 「노출·톤맵(Tone Map)의 숄더(Shoulder)는 2번 시도했으나 기각, 원리적으로 해결 불가」라는 결론은 오류였다. 실측하면 해결할 수 있다.
그리고 구체적인 수치까지 제시해 왔다. 내가 확인해 보니, 그 말이 맞았다.
| 변경 전 | 숄더를 0.72 → 0.40 |
|---|---|---|
| 햇빛을 받는 파사드(Facade)의 L>225 | 18.18% | 0.02% |
| ... | | |
벽만 낮아졌을 뿐, 바닥은 거의 움직이지 않았다. 내가 「해결할 수 없다」고 말했던 것은 파라미터 하나로 해결되었다.
무엇을 잘못했을까. 알베도(Albedo)에 대한 실측을 노출과 톤 커브(Tone Curve)에까지 외삽(Extrapolation)한 것이다. 이 세 가지는 모두 「밝기」에 영향을 주므로 직관적으로는 같아 보이지만, 곡선 위의 어느 지점에 작용하는지가 다르다. 알베도는 입력 측이고, 숄더는 출력의 상단이다. 숄더는 「상단만을 완만하게 만들기」 위한 도구이며, 바로 그 목적을 위해 존재한다. 실제로 값을 조절해 보았다면 5분 만에 알 수 있었을 일을, 실측하지 않고 결론지어 버렸다.
도달 불가능하다는 선언도 실측을 동반하지 않으면 단순한 주관에 불과하다. 이것이 평가표에 「실측을 통해 도달 불가능함을 증명·문서화 완료」라는 표현을 사용한 이유이기도 하다. 「증명」이라고 적혀 있으면, 다음에 읽는 사람은 근거가 되는 실측값을 찾을 수 있다. 없다면 의심할 수 있다.
(참고로, 별도의 항목인 「암부 바닥의 평탄대」에 대해서는, 실제로 톤(Tone)의 형태를 조절하며 스윕(Sweep)한 뒤에 「톤 커브로는 해결할 수 없다」고 결론지었다. 바닥을 지우면 산(Peak)이 L=0으로 이동할 뿐이며, 게다가 화면의 20.18%가 새카맣게 뭉개졌다. 진정한 원인은 최심부 차폐(Occlusion)에 간접광이 없는 것이었으므로, 그레이드(Grade) 측이 아닌 상류(Upstream)의 문제로 분류하여 다음 단계로 넘겼다. 같은 「해결할 수 없다」라도, 실측을 동반하느냐에 따라 의미가 완전히 다르다.)
여기까지의 이야기를 뒷받침하는 구체적인 구현을 남겨둔다.
생각은 단순하다. 대상 메쉬(Mesh)를 숨긴 이미지를 찍고, 원래 이미지와의 차분(Difference)을 구한다. 차이가 발생한 픽셀이 바로 그 물체가 차지하고 있던 픽셀이다.
Playwright로 브라우저를 조작하는 부분은 실제 프로젝트(Three.js 앱)에 의존하므로 발췌하여 보여준다.
// 【발췌·Three.js 앱 + Playwright 전제】
// 지정한 이름의 메쉬만 지운 이미지와의 차분으로 마스크를 생성
async function maskByMeshName(page, shotName, namePredicateSrc, thresh = 18) {
...
차분으로부터 마스크를 만드는 부분과 그것을 사용하는 측정 함수는 순수 함수(Pure Function)이므로, 그대로 동작하는 형태로 보여준다.
// demo_mask.mjs — Node에서 그대로 동작함
/** 2장의 RGBA로부터 「사라진 물체의 픽셀」만 1로 만든 마스크를 생성 */
function buildObjectMask(on, off, w, h, thresh = 18) {
...
실행 결과.
마스크 픽셀 수: 2304 (기대값 48*48 = 2304)
전체 화면(배경 포함)의 국소 표준편차(Local SD) 중앙값: 0
오브젝트 마스크 내의 국소 표준편차(Local SD) 중앙값: 6.75
같은 그림, 같은 지표에서 0과 6.75. 배경이 넓을수록 중앙값은 배경에 지배된다. 비평 에이전트가 「적의 윤곽선 대비(Contrast)는 실효 제로」라고 말한 것은 바로 이 때문이었다.
구현상의 주의점 두 가지.
- 임계값(Threshold)이 필요하다. 포스트 프로세스(AO·Bloom·SSR)가 있으면 물체를 지우는 순간 주변 픽셀도 미세하게 변한다. 임계값이 없으면 화면의 대부분이 마스크가 된다. 나는
thresh를 18(RGB 절대 차이의 합)로 설정했다. - 윈도우(Window)가 모두 마스크 내에 있는 위치만 사용한다. 단 1픽셀이라도 벗어나는 윈도우를 허용하면, 물체의 윤곽선(=배경과의 단차)을 포착하여 값이 실제보다 크게 나온다.
국소 표준편차(Local SD)에는 근본적인 약점이 있다. 정보량은 측정할 수 있지만, 정보의 질은 측정할 수 없다.
이것이 드러난 것이 총(Gun)이었다. 국소 표준편차(Local SD)는 충분히 높지만, 그림은 「사암을 깎아 만든 각목」처럼 보인다. 원인을 조사하기 위해 구조 텐서(Structure Tensor)의 일관성(Coherence)을 측정했다.
총의 수지 부품 Coherence 중앙값: 0.354
같은 그림의 자갈: 0.428
같은 그림의 햇빛을 받는 모래: 0.419
총의 표면이 자갈이나 모래보다 방향성이 없었다. 게다가 normalScale을 0으로 설정하자 국소 표준편차(Local SD)가 8.30 → 2.30까지 떨어졌다. 즉, 총의 정보량은 거의 전부가 **노멀 맵(Normal Map)의 등방성 입자 노이즈(Isotropic Grain Noise)**로 이루어져 있었다.
인공물은 가공 방향을 가진다 (압출 성형의 결, 기계 가공의 헤어라인 (Hairline), 파팅 라인 (Parting Line), 성형의 드래프트 (Draft)). 등방성 입자(Isotropic grain)만 있는 표면은 SD(표준편차)가 아무리 높더라도 "자연물"처럼 보인다.
coherence(결맞음)는 국소 기울기(Local gradient)로부터 구조 텐서(Structure tensor)
J = [ Σgx² Σgxgy ]
[ Σgxgy Σgy² ]
를 만들고, 고유값 λ1 ≥ λ2의 분리도 (λ1-λ2)/(λ1+λ2)를 취한다. 이는 행렬을 분해하지 않아도, √((Jxx-Jyy)² + 4Jxy²) / (Jxx+Jyy)로 직접 계산할 수 있다.
// demo_coh.mjs — Node에서 그대로 동작
/** 구조 텐서의 coherence (9x9) 중앙값. 등방성 입자인지, 방향성이 있는 흔적인지를 구분함 */
function coherenceMedian(L, w, h, mask = null) {
...
실행 결과.
A 등방성 입자 SD= 11.59 coherence= 0.136
B 방향성 있는 흔적 SD= 10.15 coherence= 0.947
SD는 거의 비슷하지만 (11.59와 10.15), coherence는 0.136과 0.947로 7배 차이가 난다. "정보량이 부족한" 것인지 "정보의 질이 다른" 것인지를 분리할 수 있다.
평가표에는 "무기 표면의 방향성 (구조 텐서 coherence 중앙값) ≥ 0.55"라는 항목을 추가했다. 대응책도 명확해진다. 등방성 입자를 줄이고, 잃어버린 정보량을 "선"으로 되찾는다 (선은 방향을 가지므로 coherence가 높아진다).
본론 1의 함정 4에서 썼던 대로. 포인트만 재게시한다.
--outDir를 나누어도 src/는 공유된다. 소스의 분리가 되지 않는다 - 수치를 보고할 때만 git worktree add /tmp/wt-me HEAD + 패치 적용 - 포트도 --strictPort로 전용화한다. before와 after의 차이가 패치 1장 분량임을 구조적으로 보장한다
이 기사의 주장을 다시 한번 말하겠다.
AI에게 품질을 채점하게 하지 마라. 결함을 찾아내게 해라.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기