ORM·RMA 채널 패킹 순서 오류를 자동 탐지하는 PBR 텍스처 검수 툴 만들기
ORM과 RMA 텍스처의 채널 순서를 자동 검증하고 메탈릭·러프니스·AO 특성으로 의심 파일을 분류하는 PBR 아트 검수 툴 설계와 Python 구현 방법을 설명합니다.

핵심 요약
ORM은 보통 R=Occlusion, G=Roughness, B=Metallic 순서이고 RMA는 R=Roughness, G=Metallic, B=Ambient Occlusion 순서입니다. 두 규격은 같은 세 장의 흑백 맵을 다른 채널에 배치하므로 순서를 바꾸면 셰이더가 AO를 러프니스로 읽는 식의 시각적 오류가 발생합니다.
검수 툴은 먼저 프로젝트 설정으로 기대 순서를 확정한 뒤 채널별 히스토그램, 이진성, 공간 주파수, 파일명·메타데이터를 함께 검사해야 합니다. 통계만으로 원본 맵의 정답을 증명할 수는 없으므로 PASS, WARN, FAIL과 근거를 JSON으로 출력하고 최종 승인 기준은 프로젝트 규칙으로 고정하는 방식이 안전합니다.
왜 ORM·RMA 채널 순서 오류가 발생하는가?
PBR 텍스처는 저장 공간과 샘플링 비용을 줄이기 위해 서로 다른 흑백 맵을 하나의 RGB 이미지에 패킹합니다. 문제는 엔진과 셰이더마다 채널 규약이 다르다는 점입니다. 같은 PNG라도 셰이더가 어떤 채널을 어떤 의미로 읽느냐에 따라 재질의 결과가 완전히 달라집니다.
| 규격 | Red 채널 | Green 채널 | Blue 채널 | 흔한 사용 맥락 |
|---|---|---|---|---|
| ORM | Occlusion | Roughness | Metallic | 엔진 기본 셰이더, 커스텀 PBR |
| RMA | Roughness | Metallic | Ambient Occlusion | 일부 머티리얼 파이프라인 |
여기서 Ambient Occlusion은 프로젝트에 따라 AO, Occlusion 또는 Cavity와 혼용되기도 합니다. 이름이 비슷해도 Cavity를 AO 슬롯에 넣어도 되는지는 셰이더의 의도와 베이킹 규칙을 확인해야 합니다.
채널 순서 오류는 다음과 같은 경로로 생깁니다.
- DCC 툴에서 내보낸 맵 순서와 엔진 임포트 규칙이 다릅니다.
- Substance 3D Painter의 export preset과 엔진 셰이더의 입력 슬롯이 일치하지 않습니다.
- 아티스트가 RGB 채널을 수동으로 복사하는 과정에서 두 채널을 교환합니다.
- 파일명은
ORM인데 실제 데이터는RMA인 상태로 커밋됩니다. - 텍스처 리임포트 때 sRGB, 압축, 채널 스위즐 설정이 바뀝니다.
따라서 검수 대상은 단순한 이미지 색상이 아니라 다음 세 가지의 조합이어야 합니다.
- 파일명이 선언한 포맷
- 파이프라인 설정이 요구하는 채널 순서
- 이미지 데이터가 보이는 통계적 특성
ORM과 RMA의 채널 매핑은 어떻게 비교할까?
두 포맷은 다음처럼 같은 의미의 맵을 서로 다른 위치에 둡니다.
| 의미 | ORM 위치 | RMA 위치 | 잘못 읽었을 때의 대표 증상 |
|---|---|---|---|
| Occlusion | R | B | 홈과 접합부의 음영이 러프니스 변화처럼 보임 |
| Roughness | G | R | 표면의 거칠기와 반사가 뒤바뀜 |
| Metallic | B | G | 금속 영역이 비금속처럼 보이거나 반사가 깨짐 |
ORM을 RMA로 착각하면 셰이더는 ORM의 R 채널인 AO를 Roughness로 읽고 G 채널인 Roughness를 Metallic으로 읽습니다. 특히 Roughness가 중간값을 많이 갖는 자산에서는 비금속 영역이 금속처럼 해석될 수 있어 스펙큘러 응답이 크게 달라집니다.

검수 툴의 핵심은 실제 데이터의 의미를 이미지 통계만으로 단정하지 않는 것입니다. Metalness가 0과 1에 몰리는 경향은 강한 힌트지만 스타일라이즈드 재질이나 마스크 기반 자산에서는 예외가 많습니다. 반대로 AO와 Roughness는 둘 다 회색조 연속값을 가질 수 있으므로 단일 평균값으로 구분하면 오탐이 쉽게 발생합니다.
PBR 텍스처 검수 툴은 어떤 구조로 만들까?
입력은 텍스처 파일과 검수 프로파일입니다. 프로파일에는 포맷, 파일명 토큰, 허용 해상도, 색 공간, 채널별 검사 기준을 넣습니다. 출력은 사람이 읽는 콘솔 리포트와 CI에서 처리하기 쉬운 JSON 리포트로 나눕니다.
flowchart LR
A[텍스처 스캔] --> B[파일명에서 포맷 추출]
B --> C[RGB 채널 통계 계산]
C --> D[설정된 채널 규칙과 비교]
D --> E{판정}
E -->|기준 충족| F[PASS]
E -->|근거 부족 또는 경계값| G[WARN]
E -->|명백한 규칙 위반| H[FAIL]
F --> I[JSON 리포트]
G --> I
H --> I
권장 검수 항목은 다음과 같습니다.
| 검사 항목 | 목적 | 자동 판정 예시 |
|---|---|---|
| 포맷 선언 | ORM·RMA 규격 확인 | 파일명 토큰과 프로파일 비교 |
| 채널 수 | RGB 패킹 여부 확인 | RGB 또는 RGBA만 허용 |
| 색 공간 | 데이터 맵의 선형 처리 보장 | ORM·RMA는 sRGB 비활성 권장 |
| Metalness 히스토그램 | 금속 마스크 특성 확인 | 0·1 주변 비율이 높은지 검사 |
| 채널 상관도 | 유사 채널 복제와 스왑 후보 확인 | 상관계수가 비정상적으로 높은지 검사 |
| 해상도·비율 | 텍스처 규격 통일 | 2의 거듭제곱과 정사각형 여부 검사 |
| 알파 채널 | 숨은 데이터 혼입 방지 | 사용하지 않는 알파 존재 시 경고 |
채널별 통계로 오류 후보를 어떻게 찾을까?
이미지의 각 채널을 0부터 1 사이의 값으로 정규화하고 다음 통계를 계산합니다.
- 평균과 표준편차: 채널의 전체 밝기와 분포를 확인합니다.
- 이진성: 0 또는 1에 가까운 픽셀의 비율입니다. Metalness 마스크에서 높은 값이 자주 나타납니다.
- 유효 범위: 거의 한 값으로만 구성된 빈 채널을 찾습니다.
- 채널 간 상관계수: 동일한 맵을 복제했거나 잘못 복사한 경우를 찾는 단서입니다.
- 저해상도 변환 후의 분산: AO처럼 큰 형태를 따르는 채널과 세부 노이즈가 많은 채널을 구분하는 보조 지표입니다.
이 중 Metalness의 이진성은 다음처럼 계산할 수 있습니다.
여기서 은 0.02처럼 작은 허용 오차이며 N(x)는 전체 픽셀 수입니다. B가 높다고 해서 반드시 Metalness라는 뜻은 아니지만 ORM의 B 또는 RMA의 G 채널이 Metalness일 가능성을 평가하는 근거가 됩니다.
Roughness와 AO는 통계적으로 겹칠 수 있으므로 이 둘을 바꾸었다고 자동 확정하지 말고 WARN으로 분류하는 편이 좋습니다. 확정이 필요한 경우에는 원본 흑백 맵의 체크섬, 아틀라스 생성 전 중간 산출물 또는 DCC export manifest를 함께 비교해야 합니다.
Python으로 기본 검수기를 어떻게 구현할까?
아래 예제는 Python 3.12, Pillow 10 이상, NumPy 2 이상을 기준으로 한 최소 구현입니다. 파일명에 orm 또는 rma가 포함되어 있다고 가정하고 이미지 데이터의 기본 이상 징후를 JSON으로 출력합니다.
from __future__ import annotations
import argparse
import json
from pathlib import Path
import numpy as np
from PIL import Image
SUPPORTED = {'.png', '.tga', '.jpg', '.jpeg', '.webp'}
def channel_stats(channel: np.ndarray) -> dict[str, float]:
values = channel.astype(np.float32) / 255.0
binary_ratio = float(
np.mean((values <= 0.02) | (values >= 0.98))
)
return {
'mean': round(float(values.mean()), 5),
'std': round(float(values.std()), 5),
'binary_ratio': round(binary_ratio, 5),
'min': round(float(values.min()), 5),
'max': round(float(values.max()), 5),
}
def correlation(a: np.ndarray, b: np.ndarray) -> float:
left = a.astype(np.float32).reshape(-1)
right = b.astype(np.float32).reshape(-1)
if float(left.std()) == 0.0 or float(right.std()) == 0.0:
return 1.0
return round(float(np.corrcoef(left, right)[0, 1]), 5)
def inspect_texture(path: Path, expected_format: str) -> dict:
result = {
'file': str(path),
'expected_format': expected_format,
'status': 'PASS',
'errors': [],
'warnings': [],
}
try:
with Image.open(path) as image:
result['mode'] = image.mode
result['size'] = list(image.size)
width, height = image.size
rgb = np.asarray(image.convert('RGB'))
except Exception as error:
result['status'] = 'FAIL'
result['errors'].append(f'image_read_error: {error}')
return result
if width == 0 or height == 0:
result['status'] = 'FAIL'
result['errors'].append('invalid_dimensions')
return result
channels = [rgb[:, :, index] for index in range(3)]
result['channels'] = {
name: channel_stats(channels[index])
for index, name in enumerate(('R', 'G', 'B'))
}
result['correlation'] = {
'RG': correlation(channels[0], channels[1]),
'RB': correlation(channels[0], channels[2]),
'GB': correlation(channels[1], channels[2]),
}
if 'A' in result['mode']:
result['warnings'].append('alpha_channel_present')
expected_metal_channel = 2 if expected_format == 'ORM' else 1
metal_name = ('R', 'G', 'B')[expected_metal_channel]
metal_binary_ratio = result['channels'][metal_name]['binary_ratio']
if metal_binary_ratio < 0.75:
result['warnings'].append(
f'metalness_channel_is_not_mostly_binary: {metal_name}'
)
for pair, value in result['correlation'].items():
if abs(value) >= 0.995:
result['warnings'].append(
f'near_duplicate_channels: {pair}={value}'
)
if result['warnings']:
result['status'] = 'WARN'
return result
def infer_format(path: Path) -> str | None:
name = path.stem.lower()
if 'orm' in name:
return 'ORM'
if 'rma' in name:
return 'RMA'
return None
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument('root', type=Path)
parser.add_argument('--format', choices=('ORM', 'RMA'))
args = parser.parse_args()
reports = []
for path in sorted(args.root.rglob('*')):
if path.suffix.lower() not in SUPPORTED:
continue
texture_format = args.format or infer_format(path)
if texture_format is None:
reports.append({
'file': str(path),
'status': 'WARN',
'warnings': ['format_token_not_found'],
})
continue
reports.append(inspect_texture(path, texture_format))
print(json.dumps(reports, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
실행 예시는 다음과 같습니다.
python pbr_validator.py ./Textures/Environment --format ORM > pbr-report.json
이 구현에서 중요한 점은 FAIL을 보수적으로 사용하는 것입니다. 파일을 열 수 없거나 RGB 기반 패킹 조건을 위반한 경우처럼 명백한 문제만 실패 처리하고 Metalness 분포나 채널 상관도처럼 자산 스타일에 영향을 받는 항목은 경고로 남깁니다.
파일명과 프로파일을 검수 기준에 포함하려면?
이미지 통계는 순서 오류의 후보를 찾는 데 유용하지만 포맷의 권위 있는 선언이 될 수 없습니다. 실무에서는 텍스처 이름과 별도의 manifest를 함께 사용합니다.
{
"version": 1,
"packing": "ORM",
"color_space": "linear",
"channels": {
"R": "occlusion",
"G": "roughness",
"B": "metallic"
},
"allow_alpha": false,
"max_resolution": 4096
}
manifest가 있다면 검수기는 다음 순서로 동작할 수 있습니다.
- 파일명 또는 폴더 규칙으로 자산 ID를 찾습니다.
- 자산 ID에 연결된 manifest의
packing과 채널 매핑을 읽습니다. - 텍스처 임포트 설정에서 sRGB가 꺼져 있는지 확인합니다.
- 이미지의 RGB 채널을 읽어 빈 채널, 중복 채널, 예상하지 않은 알파를 검사합니다.
- 결과에 규칙 위반과 통계 근거를 함께 기록합니다.
엔진에 직접 연결할 수 있다면 이미지 파일만 검사하지 말고 임포트 결과도 확인해야 합니다. 예를 들어 Unity에서는 TextureImporter.sRGBTexture와 플랫폼별 압축 설정을 Editor 스크립트로 검사할 수 있고 Unreal Engine에서는 텍스처의 sRGB 설정과 머티리얼 샘플러의 색 공간 처리를 프로젝트 규칙으로 검사할 수 있습니다. 두 엔진의 API와 에셋 저장 방식은 버전에 따라 달라지므로 실제 프로젝트 버전에 맞춰 어댑터를 분리하는 것이 좋습니다.

순서 오류를 더 정확하게 자동 판정하는 방법은?
1단계: 포맷별 규칙을 코드가 아니라 데이터로 정의한다
ORM과 RMA를 if 문에만 하드코딩하면 팀의 새 패킹 규격을 추가할 때 수정 범위가 커집니다. 다음처럼 포맷을 데이터로 표현하면 같은 검사 엔진을 재사용할 수 있습니다.
PACKING_RULES = {
'ORM': {'R': 'occlusion', 'G': 'roughness', 'B': 'metallic'},
'RMA': {'R': 'roughness', 'G': 'metallic', 'B': 'occlusion'},
}
이 매핑은 셰이더의 실제 입력과 반드시 일치해야 합니다. 문서에 ORM이라고 적혀 있어도 셰이더가 R=AO, G=Metallic, B=Roughness로 읽는다면 검수 프로파일은 문서가 아니라 실행되는 셰이더 규칙을 기준으로 만들어야 합니다.
2단계: 의심 점수를 채널별로 계산한다
채널 순서 후보를 여러 개 만들어 입력 이미지의 특성과 비교할 수 있습니다. 예를 들어 Metalness의 이진성 점수는 다음처럼 정의할 수 있습니다.
S_metal이 높은 채널을 Metalness 후보로 보고 프로파일이 요구하는 Metalness 채널과 다르면 WARN을 발생시킵니다. 단, 천, 피부, 흙처럼 비금속 재질만 모인 텍스처에서는 모든 채널이 0에 가까울 수 있으므로 파일 단위가 아니라 머티리얼 단위의 대표 샘플도 함께 고려해야 합니다.
좀 더 안정적인 점수는 다음 항목의 가중합으로 만들 수 있습니다.
- : 이진성 점수
- : 유효한 값 범위 점수
- : 히스토그램 형태 점수
- : manifest가 선언한 의미와의 일치 점수
- : 프로젝트가 정한 가중치
실제로는 manifest 일치 점수에 가장 큰 가중치를 주고 이미지 통계는 보조 신호로 두는 편이 안전합니다.
3단계: 기준 이미지를 사용해 채널 스왑을 검증한다
원본 AO, Roughness, Metallic 맵을 보관할 수 있다면 통계 기반 추정 대신 이미지 유사도 비교를 사용합니다. 패킹 전 맵과 패킹 후 채널을 같은 해상도로 맞춘 뒤 평균 절대 오차를 계산합니다.
예를 들어 원본 Metallic과 패킹 이미지의 B 채널 MAE가 가장 작아야 ORM 규칙에 맞다고 판단할 수 있습니다. 리사이즈나 압축 때문에 완전히 같은 값이 나오지 않을 수 있으므로 프로젝트에서 허용 오차를 정하고 압축 전 원본을 비교 대상으로 사용할지 결정해야 합니다.
4단계: 사람이 확인할 수 있는 진단 이미지를 생성한다
JSON만 남기면 아티스트가 문제를 찾기 어렵습니다. 검수 툴은 다음 결과물을 함께 만들면 좋습니다.
- RGB 원본 미리보기
- R, G, B 채널을 각각 회색조로 표시한 이미지
- 포맷에 따른 의미 라벨이 붙은 채널 시트
- 의심되는 Metalness 채널의 히스토그램
- 실패한 규칙과 파일 경로를 담은 HTML 또는 Markdown 리포트
특히 ORM 파일의 R 채널에 Occlusion 라벨을 붙였는데 실제로는 Roughness처럼 보이는 상황을 시각화하면 리뷰 시간이 크게 줄어듭니다. 다만 채널의 시각적 인상만으로 확정하지 말고 원본 맵 또는 manifest 결과를 함께 표시해야 합니다.
CI와 아트 리뷰에 검수기를 어떻게 연결할까?
텍스처가 저장소에 들어오는 시점에 자동 실행하면 오류가 엔진에서 발견되기 전에 차단할 수 있습니다. 일반적인 파이프라인은 다음과 같습니다.
- 아티스트가 텍스처와 manifest를 커밋합니다.
- CI가 변경된 텍스처만 추려 검수기를 실행합니다.
FAIL이 있으면 빌드를 실패시키고WARN은 리포트에 남깁니다.- 리포트에 채널 통계와 예상 매핑을 표시합니다.
- 아트 리드가 경고를 승인하거나 규칙을 조정합니다.
GitHub Actions 같은 CI에서는 검사 결과의 종료 코드를 분리하는 것이 유용합니다. 예를 들어 FAIL은 종료 코드 1로 반환하고 WARN만 있는 경우에는 종료 코드 0을 반환하되 아티팩트로 JSON 리포트를 업로드합니다. 팀이 경고를 무시하기 시작하면 경고 임계값을 낮추기보다 실제 오탐 사례를 분석해 규칙과 자산 분류를 보완해야 합니다.
검수 기준은 다음처럼 명시적으로 문서화할 수 있습니다.
| 규칙 | 기본값 예시 | 판정 |
|---|---|---|
| 지원 포맷 | PNG, TGA, WebP | 그 외 FAIL |
| 텍스처 색 공간 | Linear | sRGB 활성화 시 FAIL 또는 WARN |
| 알파 | 사용 안 함 | 알파 존재 시 WARN |
| 해상도 | 최대 4096, 2의 거듭제곱 | 위반 시 FAIL |
| Metalness 이진성 | 0.75 이상 | 미달 시 WARN |
| 중복 채널 상관도 | 절댓값 0.995 미만 권장 | 초과 시 WARN |
| 포맷 선언 | manifest 필수 | 누락 시 FAIL |
자주 묻는 질문 (FAQ)
ORM과 RMA는 같은 텍스처인가요?
같은 세 종류의 맵을 담을 수 있지만 채널 배치가 다릅니다. ORM은 일반적으로 R=AO, G=Roughness, B=Metallic이고 RMA는 R=Roughness, G=Metallic, B=AO입니다. 프로젝트 셰이더 규칙을 최종 기준으로 확인해야 합니다.
히스토그램만으로 채널 순서를 100% 맞힐 수 있나요?
아니요. Metalness는 이진 분포가 자주 나타나지만 AO와 Roughness는 자산에 따라 분포가 겹칩니다. 원본 맵, export manifest, 머티리얼 설정을 함께 비교해야 확정 판정에 가까워집니다.
ORM·RMA 텍스처는 sRGB를 켜야 하나요?
일반적으로 조명 계산에 직접 사용되는 AO, Roughness, Metallic 데이터는 선형 값으로 다루므로 sRGB를 끕니다. 다만 엔진과 압축 파이프라인의 예외가 있을 수 있으니 프로젝트의 텍스처 임포트 규칙을 검수 프로파일에 고정해야 합니다.
정리
ORM·RMA 채널 패킹 오류는 이미지가 깨진 문제가 아니라 채널의 의미와 셰이더의 읽기 순서가 어긋난 문제입니다. 가장 실용적인 검수기는 포맷 매핑을 manifest로 선언하고 이미지 통계를 보조 신호로 사용하며 결과를 JSON과 진단 이미지로 남깁니다.
자동화의 목표는 모든 예외를 통계만으로 맞히는 것이 아니라 명백한 오류를 커밋 전에 잡고 애매한 자산을 빠르게 리뷰 큐로 보내는 것입니다. 프로젝트가 사용하는 ORM·RMA 규격과 엔진 임포트 설정을 먼저 하나의 데이터 규칙으로 정리하면 이후의 셰이더, VFX, 라이팅 아트 검수에도 같은 구조를 확장할 수 있습니다.



