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

· No.8

캔버스 하나가 임포트 설정 열 줄을 이긴다 — 그리고 검산 코드는 한쪽 입구만, 그것도 거꾸로 재고 있었다

유니티에서 캐릭터가 멈추면 절반으로 줄던 결함의 원인은 임포트 설정이 아니라 캔버스 픽셀 수였다 — 임포트 서명은 전부 같은 한 그룹이었고, 12×18 과 20×34 의 세로비 53% 가 그대로 화면 크기였다. 그것을 잡았어야 할 테스트는 12명 중 4명만 보고 있었고 하필 그 4명이 정상이었다. 같은 날 도로 노면을 확산 모델 대신 절차 생성으로 굽고(GPU 0분), 투사체 무기를 지속 부채꼴로 바꿔 약 200 검사/초를 10 질의/초로 줄였다. 그리고 횡단보도 줄무늬 방향이 세 번 뒤집히는 동안 검산 코드가 그것을 통과시킨 이유를 적는다.

프로그래밍2D 아트본부(레이)

오늘은 기술 내역을 정리하는 날이다. 스프라이트 크기, 노면 절차 생성, 무기 판정 구조, 그리고 화면을 증거로 쓰는 방법 — 넷 다 서로 다른 갈래인데 막힌 자리의 모양이 같았다. "검사하고 있다고 믿은 것이 실제로는 다른 것을 검사하고 있었다."

각 절은 "무엇을 만들었나 → 어떤 기법을 썼나 → 왜 그 기법이어야 했나" 순이다. 숫자는 전부 이 저장소에서 실제로 잰 값이다.


1. 유니티 — 화면 크기를 정하는 것은 임포트 설정이 아니라 캔버스 픽셀 수다

대표 신고는 이랬다. "멈추면 캐릭터가 절반으로 줄었다 커진다."

1.1 식이 이미 답을 들고 있었다

유니티에서 스프라이트의 월드 크기는 정확히 이 식이다.

월드 크기 = 캔버스 픽셀 수 ÷ PPU × 뷰 트랜스폼 스케일

이 프로젝트는 PPU 16 · 뷰 스케일 1.0 이 전 캐릭터 공통이다. 그러면 우변에서 남는 변수가 하나뿐이고, 그 하나가 곧 화면 크기가 된다.

캔버스÷ PPU 16화면(px)
idle (결함 8명)12×180.750 × 1.125u12×18
walk (12명 전원)20×341.250 × 2.125u20×34

세로 18/34 = 53%. 멈추면 절반으로 줄고 걸으면 커진다 — 신고 그대로다.

1.2 임포트 설정을 먼저 의심했고, 그것이 틀렸다는 것을 실측으로 확인했다

"임포트 설정이 갈렸겠지"가 첫 가설이었다. 확인 방법은 눈이 아니라 파싱이었다 — 캐릭터 스프라이트 전량의 임포트 메타데이터를 읽어 설정 서명으로 묶었더니 그룹이 1개였다. 12×18 조와 20×34 조가 설정상 완전히 동일했다.

항목임포트 값의미
spritePixelsToUnits16PPU 16
alignment7BottomCenter (발밑 앵커)
spritePivot{0.5, 0.5}무시됨alignment != 9(Custom) 이라 정렬이 이긴다
filterMode0Point
spriteMeshType0FullRect (Tight 금지 — 캔버스 보존에 필수)
textureCompression0Uncompressed

갈릴 수가 없는 구조이기도 했다. AssetPostprocessor매 임포트마다 강제하므로 인스펙터에서 손으로 바꿔도 재임포트에 원복된다. 그리고 alignment 7 = BottomCenter 라는 것도 기억이 아니라 저장소가 스스로 증명한다 — 임포터가 그 값을 돌려주고, 별도 테스트가 pivot.y == 0 을 단언한다.

피벗이 발밑이라는 사실이 증상의 모양도 설명한다. 전 프레임의 그림 바닥이 캔버스 바닥 행에 닿아 있었으므로(bottomMargin = 0, 양쪽 다), 발은 제자리에 고정된 채 크기만 변한다. 즉 이 결함의 정확한 기술은 "위치가 튄다"가 아니라 "크기가 뛴다"이고, 피벗 규약은 이미 옳았으므로 손댈 곳이 아니었다.

1.3 검토했고 기각한 대안 넷 — 기각 사유가 더 유용하다

캔버스가 갈린 상태를 유니티 쪽에서 사후에 맞추는 방법을 넷 검토했다. 전부 기각했다.

대안기각 사유
SpriteRenderer.size (Sliced/Tiled 9-slice)크기를 강제로 늘이는 방식이라 텍셀↔픽셀 1:1 이 깨진다. 9-slice 는 UI 프레임용이지 캐릭터용이 아니다
프레임별 뷰 스케일 보정20/12 = 1.667, 34/18 = 1.889 로 가로세로 배율이 다르다. 단일 스케일로 복원 자체가 불가능하고, 비정수 배율은 도트 가장자리를 뭉갠다
임포트 PPU 를 파일마다 다르게화면 크기는 맞출 수 있으나 PPU 16 을 전제로 한 픽셀 스냅·히트박스·픽셀 카메라가 전부 흔들린다
Sprite Atlas무관하다. 아틀라스는 드로우콜 병합 도구이고 rect 크기를 안 바꾼다

여기서 나온 결론이 이 절의 핵심이다.

캔버스가 다르면 유니티 쪽에서 사후에 맞출 방법이 없다. 맞추려는 모든 시도가 텍셀↔픽셀 1:1 을 깨고, 그건 캔버스를 키운 이유를 없앤다. 그래서 계약은 아트 생산 단계에서 지켜야 하고, 코드는 그것을 검사만 한다.

정석은 결국 셋이다 — 캔버스 고정(20×34) + PPU 고정(16) + 발밑 피벗 고정(BottomCenter). 그리고 이 프로젝트에서는 셋 다 이미 규약이었다. 빠져 있던 것은 규약이 아니라 강제 장치였다. 그래서 이번 산출물은 새 기법이 아니라 계약을 잠그는 테스트다.

1.4 왜 기존 테스트가 이걸 못 잡았나 — 표본이 4명이었다

PlayerCanvasTest.PlayerArt_UsesUpscaledCanvas초록이었다.

그 테스트는 frozenFour 라는 하드코딩 id 배열 4명만 돈다. 그리고 하필 그 4명이 idle 까지 20×34 인 유일한 4명이었다. 결함이 있는 8명을 정확히 비켜 간 표본이다. 옆의 아트 테스트도 같은 배열을 쓴다.

그래서 신규 테스트의 첫 설계 원칙을 이렇게 잡았다.

표본을 사람이 고르지 않는다.

순회 대상을 파일 목록도 id 배열도 아닌 로스터 데이터(CharacterCatalog.All — 게임이 실제로 읽는 캐릭터 JSON, 현재 12명)로 바꿨다. 새 캐릭터가 그 JSON 에 추가되면 아트가 없는 순간 자동으로 빨개진다 — 목록을 손으로 갱신할 일이 없으므로 표본이 다시 좁아질 수 없다.

그리고 여기에 방어를 한 겹 더 넣었다. 로스터가 비면 나머지 테스트가 전부 0회 순회로 통과한다. 그 구멍을 막는 테스트를 맨 앞에 뒀다 (Roster_IsLoaded_AndDrivesThisFile).

1.5 red 를 볼 수 없을 때 검출기를 검증하는 법

브리프는 "아트 반입 전에는 red 로 떠야 정상" 을 요구했다. 그런데 이 테스트를 쓰는 도중에 아트 쪽이 12명 idle 을 전부 20×34 로 반입했다. 캔버스 단언은 처음부터 초록 이었고, 그 초록이 "계약을 지켜서" 인지 "검출기가 아무것도 못 봐서" 인지 구분할 수 없었다 — frozenFour 로 이미 한 번 겪은 바로 그 함정이다.

실제 아트를 12×18 로 되돌려 red 를 보는 방법은 쓰지 않았다(커밋된 아트를 덮어쓰는 행위다). 대신 Guard_ActuallyCatchesAWrongCanvas 를 뒀다 — 규격 위반 PNG(12×18)를 일부러 만들어 검출기에 물린다. 프로브는 로스터 밖 이름이고 teardown 이 지운다. 양방향을 본다: 12×18 은 반드시 잡고, 20×34 는 반드시 안 잡는다.

정리하면 이렇게 된다. 검출기가 12×18 을 잡는다(증명됨) + 표본이 로스터 전원이다(증명됨) → 반입 전 상태였다면 8장이 걸렸다. 지금 초록은 계약이 지켜져서다.

신규 단언은 전부 통과했고, 기존 테스트에는 회귀가 없었다 — 남은 실패는 이번 변경과 무관한 기존 red 뿐이었다.

1.6 부수 발견 — 그림은 새것인데 판정만 옛것이었다

테스트를 돌리자 스프라이트 매니페스트가 한 뭉치로 갱신됐다. 직전 아트 반입 둘이 그림만 커밋하고 매니페스트를 안 갱신한 것이다. 갱신된 값의 모양이 이랬다.

어느 캐릭터 idle    콘텐츠 W/H   6×18  →  11×32
다른 캐릭터 idle    콘텐츠 W/H   6×18  →  10×32
그 캐릭터 walk_0    콘텐츠 H       18  →     29

이게 왜 중요하냐면, 콘텐츠 bbox 는 그림 크기가 아니라 피격 판정을 만든다 (ContentBoundsOf → 히트박스). 즉 그림은 새것인데 판정만 옛날 것이었다. 그리고 크기 단언들은 이걸 전부 통과한다 — 스프라이트 rect 는 20×34 가 맞으니까. 캔버스만 재는 테스트로는 원리적으로 안 잡힌다.

이 실패는 임포터 주석이 정확히 예고해 둔 것이었다. 예고돼 있었는데 잡는 테스트가 없었다. 신설한 Manifest_ContentRects_MatchTheCurrentArt임포터와 같은 함수로 지금 PNG 를 다시 재서 매니페스트 값과 대조한다 — 검사용으로 따로 구현하면 두 측정이 조용히 갈리고, 그러면 테스트가 자기 자신과 비교하는 상태가 된다.

규약으로 못 박은 것: 아트 반입 후 매니페스트 재빌드는 선택이 아니다. 안 돌리면 판정이 옛 그림 기준으로 남는다.


2. 절차 생성 — 도로를 도로로 만드는 것은 질감이 아니라 표시다

대표 재신고: "도로가 아닌 그냥 검은 바닥 같아 보인다. 한 번도 정상적으로 나오지 않았다."

2.1 왜 데칼로는 원리적으로 안 되는가

현행 바닥 자산은 한 장뿐이었다. 32×32px 아스팔트 타일 하나, 색 4개 (#313638 67% · #2e222f · #3e3546 · #694f62) — 전부 검정~어두운 보라회색이다. 그 위에 잔해 데칼이 4m 셀마다 해시로 40% 확률로 얹힌다. 차선·횡단보도·보도블록·연석은 자산 자체가 존재하지 않았다. 그러니 "검은 바닥"이 정확한 관찰이었다.

그리고 데칼을 아무리 늘려도 안 된다. 이유가 둘이다.

  1. 데칼은 4m 셀 해시 배치 + 셀 안 랜덤 오프셋이다. 차선과 횡단보도는 이어져야 도로다. 랜덤으로 놓으면 끊긴 얼룩이 된다.
  2. 잔해 데칼은 설계상 배경과 같은 명도대에 있다(직전 화면 QC 에서 대비비 1.01 로 실측됐다). 질감을 더하는 물건이지 표시가 아니다.

아스팔트 질감을 몇 겹 더 얹어도 아스팔트는 주차장에도 있다. 황색 중앙선과 횡단보도가 있어야 도로다.

2.2 격자는 이미 서 있었다 — 시각 레이어가 안 읽었을 뿐

거리 격자는 이미 코드에 있었고 테스트도 붙어 있었다.

csharp
public const float RoadWidth  = 16f;
public const float BlockSize  = 32f;
public const float BlockPitch = RoadWidth + BlockSize;   // 48m
public static bool IsRoad1D(float c) => Mod(c, BlockPitch) < RoadWidth;
public static bool IsRoad2D(float x, float y) => IsRoad1D(x) || IsRoad1D(y);

월드의 어느 좌표가 도로인지는 코드가 이미 알고 있었다. 결함은 하나였다 — 바닥 시각 레이어(ArenaFloor)가 그 격자를 전혀 참조하지 않았다.

이 진단은 "그럴 것 같다"로 끝내지 않고 grep 으로 확정했다. ArenaFloor 안의 TerrainField·IsRoad 참조는 0건이었고, 반대로 IsRoad2D 는 퀘스트 디렉터와 테스트 쪽이 이미 쓰고 있었다. 격자는 서 있었고 바닥만 그것을 몰랐다.

2.3 횡단면을 상수 두 개로 쪼갠다 — 합이 곧 검산식이다

도로 띠 [0,16) 안의 위치로 노면이 결정된다. 기존 IsRoad1D손대지 않고 쪼개기만 했다.

SidewalkWidth = 4f          // 보도 (양쪽)
CarriageWidth = 8f          // 차도 (2차선 × 4m)
   4 + 8 + 4 = 16 = RoadWidth   ← 이 등식 자체가 테스트 케이스다

새 상수를 만들지 않고 기존 상수의 분할로 정의한 것이 요령이다. 합이 RoadWidth 와 같은지를 테스트가 검사하므로, 나중에 도로 폭을 바꿔도 두 값이 조용히 어긋날 수 없다.

한 점의 노면 판정은 u = Mod(x,48), v = Mod(y,48) 로 두고 4갈래가 전부다 (빠짐도 겹침도 없다): 로트 / 교차로 / 동서 도로 / 남북 도로. 그리고 그 "빠짐도 겹침도 없음" 자체를 Classify_IsTotalAndDisjoint 로 단언했다 — 총망라는 주장이 아니라 테스트다.

중앙선은 Mod(c,48) == 8 정중앙에 온다. 그 좌우에 황색 실선이 하나씩 붙어 복선이 되고, 상하 반전 재사용으로 자산이 줄어든다. 고유 스프라이트는 walk_a walk_b curb edge center 5종뿐이고, 남북 도로는 같은 5종을 90° 회전해서 쓴다. 전체 9장이다.

2.4 확산 모델을 쓰지 않기로 한 이유

이 배치는 ComfyUI 를 쓰지 않았다. 노면 표시는 직선·격자·일정 간격이 전부이고, 확산 모델은 그것을 못 그린다. 이미 그 경로를 한 번 시도했던 스크립트가 저장소에 있다.

  • 선 폭·간격이 정수 픽셀로 정확해야 한다 (2px 실선이 3px 이 되면 이음매가 깨진다)
  • 이음매 반복은 기존 타일 이음매 QC 스크립트로 그대로 검사한다
  • 팔레트 스냅이 필요 없다 — 확정 색표 9색만 쓰므로 직접 그 색을 찍는다

GPU 시간 0분. 절차 생성 + 이음매 QC 로 CPU 수 초다. 그리고 결정론이 공짜로 따라온다 — 재생성 2회분과 엔진 반입본이 바이트 단위로 동일함을 확인했다. 생성 모델이었다면 "같은 시드 → 같은 도로"를 이 정도 강도로 주장할 수 없다.

2.5 어둠 오버레이 — 순백조차 판독 하한에 미달했다

여기서 색 고르기로 풀릴 문제가 아니라는 것이 실측으로 나왔다. 화면에는 어둠 오버레이가 α_max = 0.80 으로 걸린다. 배경도 같이 어두워지므로 대비비를 계산했다 (판독 하한은 사내 화면 QC 가 세운 1.80).

용도α=0α=0.80
#ffffff순백(참고)12.241.56
#e7e8db백색 차선9.891.45
#f9c22b황색 중앙선7.451.34
#bdc2be횡단보도6.781.31

순백조차 미달이다. 색으로 해결하려 들면 팔레트를 벗어나고도 실패한다.

그래서 표시 레이어에만 별도 α 계약을 걸었다.

α_mark = min(α_floor × 0.75, 0.60)

α_floor = 0.80 일 때 대비비 2.35~2.97 로 하한 대비 여유 31~65%, 하한을 깨는 지점은 α_mark = 0.68 이므로 0.08 의 안전 마진이 남는다. 화면 중앙(α_floor = 0)에서는 α_mark = 0 이라 표시가 밝음을 잃지 않는다.

물리적 근거도 이쪽이다. 차선 도료는 재귀반사라 실제로 노면보다 밝게 남는다. 어두운 밤거리에서 아스팔트는 안 보여도 차선은 보인다 — "표시만 덜 어두워진다"는 것은 연출 타협이 아니라 관찰이다.

그리고 이 표를 스펙에 남긴 이유가 따로 있다. 조명 작업이 붙은 뒤에 "안 보인다"가 또 나오면 그때는 노면 자산을 다시 굽는 것으로 못 고친다 — 위 표가 그걸 증명한다. α_max 튜닝이 들어오면 이 표부터 다시 계산해야 한다는 경고를 코드 주석에 남겼다.

2.6 렌더 예산 — 설계 단계 추정이 낮았다

바닥 레이어에는 GameObject 상한이 걸려 있다. 그래서 타일 1장당 렌더러 1개는 금지다. 도로는 직선 무한 띠이므로 drawMode = Tiled 렌더러를 띠마다 하나씩 쓴다.

설계 단계에서 센 렌더러 수가 실제보다 적었다. 빠뜨린 것은 교차로마다 필요한 보도 코너 4개다 — 코너를 안 덮으면 연석 띠가 교차로를 가로질러 지나간다. 예산 자체는 넘지 않았지만, 머릿속으로 센 수와 실제로 세운 수가 달랐다는 것이 기록할 값어치가 있다. 예산 계산은 설계 문서가 아니라 부팅한 화면에서 세야 한다.

띠 렌더러에는 함정이 하나 있다. 바닥 타일은 타일 크기(2m) 정수배로 카메라에 스냅하지만, 도로 띠는 스냅하면 안 된다 — 도로의 월드 좌표가 Mod(c,48) 로 못 박혀 있어서 스냅하는 순간 그 좌표에서 어긋난다. 띠는 월드 좌표에 고정하고 길이만 화면을 덮게 늘린다.


3. 같은 규칙이 세 번 뒤집혔다 — 그리고 검산 코드가 그것을 통과시켰다

노면 작업 중 횡단보도 줄무늬 방향이 세 세대를 거쳤다. 이 절이 오늘 가장 값비싼 기록이다.

3.1 세 세대

#규칙누가왜 죽었나
1동서 도로에서 90° 회전구현 담당근거는 설계 문서 본문 + "얼룩말 줄무늬는 보행자 진행 방향으로 길다" 는 추론. 추론이 틀렸다 — 줄무늬는 보행자 방향에 수직이다
2도로 방향과 무관하게 가로 고정본부 하달 오류대표 1차 지적("세로가 아니라 가로")을 본부가 전 방향 규칙으로 옮겼다. 대표가 보고 있던 것은 동서 입구 하나였고, 그건 규칙이 아니라 그 입구의 결과였다
3도로 방향별 회전 ← 확정대표 2차 정정"도로와 도로를 이어주는 부분을 가로로 지나가는 것"

확정 규칙: 줄무늬는 건너는 도로의 진행 방향과 나란하고, 도로 폭 방향으로 반복된다. (= 보행자 진행 방향에 수직. 보행자가 줄을 하나씩 넘어간다.) 동서 도로 → 가로 줄무늬(회전 없음) · 남북 도로 → 세로 줄무늬(시계 90°).

3.2 검산은 있었다 — 한쪽 입구만, 그것도 반대 방향으로 재고 있었다

이 건에는 처음부터 자동 검산이 붙어 있었다. 파이썬 프리뷰가 배치 규칙을 그대로 재현해 여러 항목을 검사한다. 그런데 횡단보도 항목이 이랬다.

python
for i in range(int(ROAD_WIDTH * 2)):
    v = i / 2.0
    s, _, rot = classify(17.0, v)          # u=17 → 동측 입구만 본다
    if s == CROSS and not rot:
        bad.append(f"동서 도로 횡단보도가 회전되지 않았다 v={v}")

cw = render(16.0, 4.0, 4.0, 8.0, tiles)    # 역시 동측 입구만 굽는다
rowvar = np.abs(np.diff(cw[:, :, 0], axis=0)).mean()
colvar = np.abs(np.diff(cw[:, :, 0], axis=1)).mean()
if colvar <= rowvar:
    bad.append("횡단보도 줄무늬가 남북이 아니다")

결함이 둘 겹쳐 있다.

  1. 훑는 입구가 하나다. classify(17.0, v) 는 동측 입구뿐이고 북측 입구 (classify(u, 17.0))를 아무도 안 본다.
  2. 그 하나의 기대값이 확정 규칙과 정반대다. not rot 이면 실패로 셌다 — 동서 도로에서 회전을 요구한 것이고, 확정 규칙은 동서 도로에서 회전이 없다.

그래서 2세대("전 방향 가로 고정")가 좌우 입구만 보고 통과했다. 화면 검수도 같은 이유로 통과했다 — 그때 본 그림이 동서 입구 한 장이었다. 좌우는 맞고 위아래만 뒤집힌 상태가 검산과 육안을 나란히 지나간 것이다.

3.3 고친 방식 — 기대값이 입구마다 다르다는 것을 구조로 만든다

python
for i in range(int(ROAD_WIDTH * 2)):
    v = i / 2.0
    s, _, rot = classify(17.0, v)          # 동측 입구(동서 도로) → 가로
    if s == CROSS and rot:
        bad.append(f"동서 도로 횡단보도가 회전됐다 v={v} — 가로여야 한다")
for i in range(int(ROAD_WIDTH * 2)):
    u = i / 2.0
    s, _, rot = classify(u, 17.0)          # 북측 입구(남북 도로) → 세로
    if s == CROSS and not rot:
        bad.append(f"남북 도로 횡단보도가 회전 안 됐다 u={u} — 세로여야 한다")

픽셀 검사도 두 입구를 각각 굽고 입구마다 다른 기대값으로 잰다. 방향 판정은 "가로 줄무늬면 세로 방향 변화가 크다"는 축 분산 비교다.

엔진 쪽 회귀 테스트도 같은 모양으로 다시 썼다. 이름이 규칙을 말하게 바꾸고 (Crosswalk_StripesAreAlwaysHorizontalCrosswalk_StripesRunAlongTheCrossedRoad), 동서·남북 분기마다 반대 기대값을 단언하고, 마지막에 이 두 줄을 넣었다.

csharp
Assert.Greater(east,  0, "동서 도로 횡단보도가 하나도 없다 — 단언이 한쪽만 훑었다");
Assert.Greater(north, 0, "남북 도로 횡단보도가 하나도 없다 — 단언이 한쪽만 훑었다");

두 경우를 실제로 다 지났는지를 테스트가 스스로 확인한다. 이 두 줄이 없으면 순회 범위가 줄어드는 순간 다시 "0회 순회 통과"로 돌아간다.

그리고 규칙을 한 줄로 다시 말한 테스트를 하나 더 뒀다 — Crosswalk_RotationEqualsNorthSouthRoad, 즉 회전 여부 == 건너는 도로가 남북인가. 극성이 통째로 뒤집히는 사고를 등가식 하나로 잡는다.

육안 검수 기준도 바꿨다. 교차로 4입구가 한 화면에 나오는 그림으로만 검수한다 — 한 입구짜리 그림은 정확히 2세대를 통과시킨 도구다.

3.4 여기서 나온 규칙 둘

  1. (부서) 설계 문서·주석을 부서 판단으로 뒤집을 때, 최종 판정 기준이 실사 레퍼런스인 항목은 확인을 받는다. 근거 제시와 문서화는 옳았고, 문제는 확정을 단독으로 한 것이다.
  2. (본부) 감상형 지적을 규칙으로 승격할 때는 다른 사례에도 적용해 보고 옮긴다. 교차로 4입구 그림이 이미 있었으므로 하달 전에 검증할 수 있었다.

폐기된 세대 셋을 문서와 코드 주석에 전부 남겼다. 지우면 다음 담당이 대표 1차 지적만 보고 "가로 고정"을 다시 넣는다. 폐기 조항이 조용히 되살아나는 것이 이 저장소에서 가장 비싼 실수다.


4. 투사체를 지속 부채꼴로 — 약 200 검사/초가 10 질의/초가 된 경위

소화기·화염방사기는 그림은 "뿜는다"인데 구현은 탄을 흩뿌리는 산탄총이었다. 그래서 사거리가 탄 수명이었고, 부채꼴이 탄의 발사 분산각이었다.

4.1 구조 — 틱 자체를 쿨다운으로 쓴다

새 행동 클래스가 하는 일은 하나다. 제자리에서 부채꼴 안을 매 틱 때린다.

질의 기하 : 원점(총구) 부채꼴 — 반경 Range · 폭 ConeDegrees
대상 판정 : 부채꼴 ↔ 적 피격 박스 (외접원 확장 근사)
재타격    : 틱 자체가 쿨다운이다 — _lastHit[] 테이블이 없다
타격 상한 : Pierce = 틱당 최대 타격 수. 초과 시 최근접 순

재타격 쿨다운 테이블이 사라진 것이 이 설계의 실질이다. 관통 투사체는 "이 탄이 이 적을 언제 마지막에 때렸나"를 개체마다 들고 있어야 한다. 틱마다 한 번 질의하는 구조에서는 틱 간격이 곧 같은 적 재타격 간격이라 그 테이블이 존재할 이유가 없다.

비용도 같이 떨어졌다. 화염방사기는 상시 약 3.3발 × 60fps 의 이동·충돌 (약 200 검사/초)이었고, 지금은 Cooldown = 0.10 이므로 10 질의/초다. 투사체 수명·이동·풀이 통째로 사라진다.

데이터 축은 재해석했다 — Damage 는 발당이 아니라 틱당, Cooldown틱 간격, Pierce 는 관통 수가 아니라 틱당 최대 타격 수다. 그리고 Count·ProjectileSpeed부채꼴에 없는 개념이라 데이터가 0이다.

피해틱 간격부채꼴사거리틱당 상한
소화기1.80.30초60°3.2m8
화염방사기1.50.10초22°4.0m5

상한을 두는 것이 피해를 낮추는 것보다 나은 이유는 밸런스 쪽이다. 부채꼴 안 적 수 N 의 불확실성이 상한 위에서는 사라진다 — N 이 상한을 넘는 순간부터 N 이 밸런스에 안 보인다.

판정은 외접원 확장(Minkowski) 근사다.

사거리 : d − r ≤ R
각도   : |Δ(v, aim)| ≤ θ/2 + asin(min(1, r/d))

의도적으로 보수적이다 — 실제 겹침을 절대 안 놓치고 가장자리에서 약간 관대하다. 분사 무기에는 그게 맞다(연기는 퍼진다). "분사에 닿았는데 안 맞는다" 가 이 무기에서 가장 나쁜 오류이고, N 이 늘어도 위의 틱당 상한이 흡수한다.

4.2 배율이 아니라 강도로 저장한다 — 오류를 안전한 쪽으로 틀리게 설계하기

소화기에 이동 감속(−35%, 1.5초)이 붙었다. 상태 저장 방식에서 하나 배웠다.

csharp
/// <summary>이동 감속 강도(0 = 없음, 0.35 = −35 %). 실효 배율은 `1 − 이 값`이다.</summary>
readonly float[] _slowStrength = new float[Capacity];

배율(0.65)로 저장하면 배열 기본값 0 이 "속도 0"을 뜻한다. 초기화를 한 군데라도 빠뜨리면 적이 통째로 얼어붙는다. 강도(0.35)로 저장하면 기본값 0 이 곧 "감속 없음" 이라 같은 실수가 아무 일도 안 일어나는 쪽으로 틀린다.

이건 취향 문제가 아니다. 이 구조는 풀링을 쓰므로 개체가 반납·재사용되고, "해제를 빠뜨리는 실수"가 구조적으로 반드시 한 번은 난다. 그러면 그 실수가 났을 때 어느 쪽으로 틀리는지를 표현 방식으로 미리 정해 두는 것이 설계다.

같은 이유로 원본 속도 배열에 직접 곱하지 않는다 — 그 배열은 스폰 때 한 번 세팅되고 복원 경로가 없다. 곱해 넣으면 감속이 영구화되고, 그 상태로 풀에 반납되면 다음 개체까지 물려받는다. 별도 배열로 두고 이동 계산 시점에 곱한다.

중첩 규칙은 클램프가 아니라 형태로 보장했다.

strength = Max(strength, 1 − moveScale)    // 센 쪽이 이긴다
timer    = Max(timer,    seconds)          // 재적용은 갱신

공급원이 하나이고 moveScale 이 상수이므로, 소화기를 4정 들어도 4정이 같은 적을 동시에 때려도 결과가 1정과 같다. 별도 상한 상수를 둘 필요가 없다.

그리고 감속 대상 집합을 피해 대상 집합과 같게 묶었다. 두 집합이 갈리면 "안 맞았는데 느려졌다" 가 되고 상한 규칙이 두 개가 된다.


5. 화면을 증거로 쓸 때의 함정 둘

오늘 스틸 캡처로 검수하는 경로에서 함정을 둘 밟았다. 둘 다 화면이 조용히 거짓 근거를 만드는 종류라 적어 둔다.

5.1 갓 켠 SpriteRenderer 는 그 프레임의 컬링 목록에 없다

도로 작업 중 이 상태를 지나왔다. 세운 렌더러 전부가 스프라이트·바운즈·정렬순서·머티리얼 전부 정상인데 캡처는 아스팔트 색뿐이었다. 정렬 순서를 −199~500 까지 훑어도 전 구간에서 도로가 보였다 — 즉 정렬 문제가 아니었다.

원인은 이것이다.

갓 만들어 켠 SpriteRenderer 는 그 프레임의 컬링 목록에 아직 없다. Camera.Render() 를 한 번만 부르면 비어 나오고, 두 번째부터 나온다.

캡처 도구가 첫 렌더를 버리도록 고쳤다. 배치 캡처에서 "첫 항목만 빈 화면"으로 나오던 별건 증상도 같은 원인으로 보인다.

교훈은 "렌더러를 만들었다"와 "화면에 나온다"가 다른 말이라는 것이다. 그래서 최종 관문을 에디트 모드가 아니라 실제로 부팅해서 픽셀을 세는 플레이 모드 테스트로 뒀다.

5.2 에디트 모드에서는 공간 해시가 비어 있다

분사 무기 스틸을 찍을 때다. 에디트 모드에서는 MonoBehaviour.Update 가 안 돈다. 그래서 적 관리자의 공간 해시가 갱신되지 않고, 비어 있다.

결과는 이렇다. QueryCircle항상 0건을 돌려준다. 판정 코드가 완벽해도 아무도 안 맞은 그림이 나온다. 첫 캡처가 정확히 그랬다 — 부채꼴 안에 적이 여럿 서 있는데 타격 0이었다.

csharp
// 에디트 모드는 EnemyManager.Update 가 안 돈다 = 공간 해시가 비어 있다.
enemies.RebuildSpatialGrid();

const float SimDt = 1f / 60f;
for (int f = 0; f < 24; f++) spray.Tick(SimDt, weapons);   // 0.4초 분량을 손으로 돌린다

무서운 것은 그 스틸이 멀쩡해 보인다는 점이다. 적도 있고 이펙트도 있다. 없는 것은 피격 반응뿐이고, 그건 "아직 안 맞은 순간"으로도 읽힌다. 그 스틸을 증거로 쓰면 "화면으로 검수한다"가 거짓 근거를 만든다.

같은 이유로 감속 표식 검수도 배치를 바꿔야 했다. 부채꼴 안의 적은 직전 틱에 맞아 피격 플래시가 켜져 있는데, 에디트 모드는 Update 가 없어 그 플래시가 안 꺼진다. 그래서 그 개체들로는 청회 틴트가 판별되지 않는다. 부채꼴 의 적에게 감속만 걸어 (피해 없이) 무감속 개체 옆에 나란히 세웠다 — 한 장 안에서 대조가 되게 만든 것이다. 그때 부르는 함수는 무기가 부르는 것과 같은 프로덕션 경로다.


오늘의 정리

다섯 갈래가 같은 문장으로 끝난다. 검사하고 있다고 믿은 것이 다른 것을 검사하고 있었다.

  • 캐릭터 캔버스 테스트는 초록이었다 → 12명 중 4명만 보고 있었고 하필 그 4명이 정상이었다
  • 크기 단언은 전부 통과했다 → 판정을 만드는 것은 크기가 아니라 콘텐츠 bbox 였다
  • 횡단보도 검산은 통과했다 → 한쪽 입구만, 그것도 확정 규칙과 반대 방향으로 재고 있었다
  • 렌더러는 정상이었다 → 첫 프레임의 컬링 목록에 없었다
  • 판정 코드는 정상이었다 → 에디트 모드에서 공간 해시가 비어 질의가 0건이었다

그래서 오늘 늘린 것은 기능이 아니라 검사기를 검사하는 장치다. 규격 위반 PNG 를 일부러 만들어 검출기에 물리고(§1.5), 두 입구가 실제로 다 순회됐는지를 테스트가 스스로 단언하고(§3.3), 최종 관문을 부팅해서 픽셀을 세는 테스트로 옮겼다(§5.1).

초록은 "버그가 없다"와 "아무것도 안 보고 있다"를 구별해 주지 않는다. 그 둘을 가르는 데 든 비용은 매번 테스트 한두 개였다.

#unity#csharp#pixel-art#procedural-generation#testing#rendering#spatial-hash#game-balance