ComfyUI 워크플로우를 CLI로 연동해 3D 텍스처 PBR 맵 자동 일괄 베이킹하기

ComfyUI API Format 워크플로우를 Python 3.11 CLI에 연결해 3D 텍스처를 일괄 처리하고 base color·roughness·metallic·normal·height 맵을 생성·저장·검증하는 실전 파이프라인을 정리합니다.

✓ 복사 완료!
챕터아트 에셋 제작 워크플로
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
ComfyUI 워크플로우를 CLI로 연동해 3D 텍스처 PBR 맵 자동 일괄 베이킹하기

핵심 요약

  • ComfyUI에서 Save (API Format)으로 내보낸 워크플로우 JSON을 Python 3.11 CLI가 읽고 입력 이미지와 시드값을 주입합니다.
  • CLI는 ComfyUI HTTP API의 /upload/image, /prompt, /history/{prompt_id}, /view 엔드포인트를 사용해 여러 자산의 PBR 맵을 순차 처리합니다.
  • ComfyUI가 생성하는 맵은 텍스처 생성 또는 변환 결과이며 고해상도·저해상도 메시 기반의 엄밀한 베이킹은 Blender Cycles나 전용 베이커에서 최종 검증해야 합니다.

PBR 맵 자동화에서 먼저 구분해야 할 것은 무엇인가?

게임 개발에서 베이킹이라는 표현은 두 가지 작업을 가리키는 경우가 많습니다. 하나는 입력 텍스처에서 PBR 맵을 추정하거나 생성하는 작업이고 다른 하나는 고해상도 메시의 표면 정보를 저해상도 메시의 UV 공간으로 투영하는 메시 베이킹입니다.

ComfyUI는 전자의 자동화에 적합합니다. 후자는 Blender, Substance 3D Painter, Marmoset Toolbag 같은 지오메트리 기반 도구가 더 적합합니다.

구분ComfyUI 기반 처리지오메트리 기반 베이킹
입력원본 이미지, 마스크, 텍스트 조건하이폴리 메시, 로우폴리 메시, UV
주요 결과base color, roughness, metallic, normal, height 추정tangent-space normal, AO, curvature, thickness
지오메트리 정보직접 사용하지 않음고해상도 표면 정보를 사용
장점대량 처리, 조건 변경, 반복 생성실제 표면 디테일과 UV 투영에 정확
주의점조명과 재질 정보가 섞인 결과가 나올 수 있음노멀 방향, 케이지, UV 겹침을 관리해야 함

따라서 실무 파이프라인은 보통 다음처럼 구성합니다.

  1. ComfyUI로 입력 텍스처를 정리하고 PBR 맵을 추정합니다.
  2. Blender 또는 전용 베이커에서 메시 기반 맵을 생성하거나 결과를 검증합니다.
  3. 엔진 임포트 규칙에 맞게 색 공간, 채널 방향, 해상도, 파일명을 정리합니다.

전체 CLI 파이프라인은 어떻게 동작하는가?

ComfyUI의 웹 화면에서 자산을 하나씩 넣는 대신, 워크플로우를 API 형식 JSON으로 저장하고 CLI가 각 입력 파일에 맞게 노드 값을 변경합니다.

flowchart LR
    A[원본 텍스처 폴더] --> B[Python 3.11 CLI]
    B --> C[ComfyUI 이미지 업로드]
    C --> D[API Format 워크플로우 실행]
    D --> E[맵별 SaveImage 노드]
    E --> F[PBR 출력 폴더]
    F --> G[Blender 또는 게임 엔진 검증]

자산 하나를 처리하는 단위는 다음과 같습니다.

단계처리 내용실패 시 대응
1입력 이미지 업로드네트워크 재시도
2LoadImage 노드에 파일명 주입워크플로우 계약 오류 기록
3KSampler 시드와 출력 접두사 설정자산별 로그 저장
4/prompt로 실행 요청HTTP 오류 재시도
5/history/{prompt_id} 폴링타임아웃 처리
6/view로 맵 다운로드누락 맵을 실패로 표시

ComfyUI 워크플로우를 CLI용 JSON으로 준비하는 방법

먼저 ComfyUI에서 필요한 노드를 연결합니다. 일반적인 PBR 워크플로우에는 다음 노드 구성이 포함됩니다.

  • LoadImage: 입력 텍스처 로드
  • 전처리 노드: 해상도 조정, 색 공간 변환, 마스크 생성
  • PBR 변환 노드 또는 커스텀 노드: roughness, metallic, normal, height 추정
  • KSampler: 확산 모델을 사용하는 경우의 샘플링 단계
  • SaveImage: 맵별 파일 저장

워크플로우를 저장할 때는 일반 화면 저장 JSON이 아니라 Save (API Format)으로 내보냅니다. API 형식에서는 각 노드의 class_type과 inputs를 직접 수정할 수 있습니다.

이 글의 예제는 다음 노드 ID를 사용한다고 가정합니다. 실제 JSON에서 노드 ID는 다를 수 있으므로 자신의 워크플로우에 맞춰 수정해야 합니다.

목적예제 노드 ID수정할 입력값
입력 이미지12image
샘플러23seed
Base Color 저장40filename_prefix
Roughness 저장41filename_prefix
Metallic 저장42filename_prefix
Normal 저장43filename_prefix
Height 저장44filename_prefix

핵심은 워크플로우를 코드에서 전부 재작성하는 것이 아니라 검증된 JSON 템플릿에 입력 파일명과 실행 파라미터만 주입하는 것입니다. 이렇게 하면 ComfyUI 화면에서 수정한 노드 구성을 그대로 배치 처리에 재사용할 수 있습니다.

ComfyUI PBR 맵 생성 워크플로우와 맵별 출력 노드 구성

Python 3.11 CLI로 ComfyUI 배치 처리를 구현하는 방법

다음 스크립트는 입력 폴더의 이미지를 순서대로 업로드하고 ComfyUI 워크플로우를 실행한 뒤 맵별 결과를 저장합니다. ComfyUI 서버의 기본 주소는 http://127.0.0.1:8188입니다.

from __future__ import annotations

import argparse
import copy
import json
import sys
import time
import uuid
from pathlib import Path

import requests

LOAD_IMAGE_NODE_ID = '12'
SAMPLER_NODE_ID = '23'
MAP_NODES = {
    'basecolor': '40',
    'roughness': '41',
    'metallic': '42',
    'normal': '43',
    'height': '44',
}
SUPPORTED_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.tif', '.tiff'}


def upload_image(session: requests.Session, base_url: str, image_path: Path) -> str:
    with image_path.open('rb') as handle:
        response = session.post(
            f'{base_url}/upload/image',
            files={'image': (image_path.name, handle, 'application/octet-stream')},
            data={'overwrite': 'true'},
            timeout=60,
        )
    response.raise_for_status()
    payload = response.json()
    return payload.get('name', image_path.name)


def patch_workflow(
    template: dict,
    uploaded_name: str,
    asset_stem: str,
    seed: int,
) -> dict:
    workflow = copy.deepcopy(template)

    workflow[LOAD_IMAGE_NODE_ID]['inputs']['image'] = uploaded_name
    workflow[SAMPLER_NODE_ID]['inputs']['seed'] = seed

    for map_name, node_id in MAP_NODES.items():
        workflow[node_id]['inputs']['filename_prefix'] = (
            f'pbr/{asset_stem}_{map_name}'
        )

    return workflow


def queue_prompt(
    session: requests.Session,
    base_url: str,
    workflow: dict,
) -> str:
    payload = {
        'prompt': workflow,
        'client_id': str(uuid.uuid4()),
    }
    response = session.post(f'{base_url}/prompt', json=payload, timeout=60)
    response.raise_for_status()
    return response.json()['prompt_id']


def wait_for_outputs(
    session: requests.Session,
    base_url: str,
    prompt_id: str,
    timeout_seconds: int,
) -> dict:
    deadline = time.monotonic() + timeout_seconds

    while time.monotonic() < deadline:
        response = session.get(
            f'{base_url}/history/{prompt_id}',
            timeout=30,
        )
        response.raise_for_status()
        history = response.json()
        item = history.get(prompt_id)

        if item:
            status = item.get('status', {})
            if status.get('status_str') == 'error':
                raise RuntimeError(f'ComfyUI failed: {status.get("messages")}')

            if item.get('outputs') and (
                status.get('completed') is True
                or status.get('status_str') in {'success', 'completed'}
            ):
                return item

        time.sleep(1)

    raise TimeoutError(f'Timed out while waiting for prompt {prompt_id}')


def download_maps(
    session: requests.Session,
    base_url: str,
    history_item: dict,
    asset_stem: str,
    output_dir: Path,
) -> list[Path]:
    output_dir.mkdir(parents=True, exist_ok=True)
    saved_paths = []

    for map_name, node_id in MAP_NODES.items():
        node_output = history_item.get('outputs', {}).get(node_id, {})
        images = node_output.get('images', [])
        if not images:
            raise RuntimeError(f'Missing output for {map_name} at node {node_id}')

        image = images[0]
        response = session.get(
            f'{base_url}/view',
            params={
                'filename': image['filename'],
                'subfolder': image.get('subfolder', ''),
                'type': image.get('type', 'output'),
            },
            timeout=60,
        )
        response.raise_for_status()

        suffix = Path(image['filename']).suffix or '.png'
        target = output_dir / f'{asset_stem}_{map_name}{suffix}'
        target.write_bytes(response.content)
        saved_paths.append(target)

    return saved_paths


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument('--input', required=True, help='source texture directory')
    parser.add_argument('--workflow', required=True, help='API Format workflow JSON')
    parser.add_argument('--output', required=True, help='PBR output directory')
    parser.add_argument('--server', default='http://127.0.0.1:8188')
    parser.add_argument('--seed', type=int, default=20260824)
    parser.add_argument('--timeout', type=int, default=900)
    args = parser.parse_args()

    input_dir = Path(args.input)
    output_dir = Path(args.output)
    template = json.loads(Path(args.workflow).read_text(encoding='utf-8'))
    base_url = args.server.rstrip('/')
    failures = 0

    with requests.Session() as session:
        for source in sorted(input_dir.iterdir()):
            if not source.is_file() or source.suffix.lower() not in SUPPORTED_EXTENSIONS:
                continue

            try:
                uploaded_name = upload_image(session, base_url, source)
                workflow = patch_workflow(
                    template=template,
                    uploaded_name=uploaded_name,
                    asset_stem=source.stem,
                    seed=args.seed,
                )
                prompt_id = queue_prompt(session, base_url, workflow)
                history_item = wait_for_outputs(
                    session=session,
                    base_url=base_url,
                    prompt_id=prompt_id,
                    timeout_seconds=args.timeout,
                )
                paths = download_maps(
                    session=session,
                    base_url=base_url,
                    history_item=history_item,
                    asset_stem=source.stem,
                    output_dir=output_dir,
                )
                print(f'[OK] {source.name} -> {len(paths)} maps')
            except Exception as exc:
                failures += 1
                print(f'[FAIL] {source.name}: {exc}', file=sys.stderr)

    return 1 if failures else 0


if __name__ == '__main__':
    raise SystemExit(main())

실행 전에 필요한 패키지를 설치합니다.

python -m pip install requests

ComfyUI를 실행한 뒤 다음처럼 배치 작업을 시작합니다.

python pbr_batch.py \
  --input ./source_textures \
  --workflow ./workflow_api.json \
  --output ./generated_pbr \
  --server http://127.0.0.1:8188 \
  --seed 20260824

Windows PowerShell에서는 줄바꿈 백슬래시 대신 한 줄로 실행하거나 백틱을 사용할 수 있습니다.

python .\pbr_batch.py --input .\source_textures --workflow .\workflow_api.json --output .\generated_pbr --server http://127.0.0.1:8188 --seed 20260824

출력 구조는 다음과 같이 단순하게 유지하는 것이 좋습니다.

source_textures/
  crate.png
  metal_panel.png

generated_pbr/
  crate_basecolor.png
  crate_roughness.png
  crate_metallic.png
  crate_normal.png
  crate_height.png
  metal_panel_basecolor.png
  metal_panel_roughness.png
  ...

실패한 ComfyUI 작업을 어떻게 재시도할까?

대량 처리에서는 단일 자산의 실패가 전체 작업을 중단하지 않도록 해야 합니다. 예제 스크립트는 자산별 예외를 잡고 다음 입력으로 넘어가며 마지막에 실패 수를 프로세스 종료 코드로 반환합니다.

운영 환경에서는 다음 정보를 별도 로그에 남기는 것이 좋습니다.

  • 원본 파일의 절대 경로 또는 프로젝트 상대 경로
  • 워크플로우 파일의 Git 커밋 또는 버전
  • ComfyUI 서버 주소와 사용한 커스텀 노드 버전
  • seed, steps, CFG, denoise strength, 해상도
  • prompt_id와 실패 메시지
  • 생성된 파일의 SHA-256 해시

네트워크 오류에는 지수 백오프를 적용할 수 있습니다. 반면 워크플로우 입력 누락이나 커스텀 노드 오류는 재시도해도 해결되지 않으므로 즉시 실패시키고 사람이 수정해야 합니다.

또한 이미지 이름에 공백이나 특수문자가 많으면 엔진별 임포터에서 문제가 발생할 수 있습니다. 배치 시작 전에 영문 소문자, 숫자, 하이픈, 언더스코어 중심으로 자산명을 정규화하면 후속 파이프라인이 안정적입니다.

맵별 색 공간과 저장 규칙은 어떻게 정해야 하는가?

PBR 맵 자동화에서 가장 흔한 오류는 모든 이미지를 sRGB로 저장하거나 normal map의 Y 채널 방향을 잘못 선택하는 것입니다.

맵데이터 성격일반적인 색 공간주요 검증 항목
Base Color색상 데이터sRGB조명 하이라이트가 제거되었는가
Roughness스칼라 데이터Linear검정은 매끄러움, 흰색은 거칠음인지
Metallic스칼라 데이터Linear대부분 0 또는 1에 가까운가
Normal방향 벡터Linear 취급OpenGL Y+와 DirectX Y-가 맞는가
Height깊이 데이터Linear클리핑과 밴딩이 없는가
AO차폐 데이터Linear과도하게 어두워지지 않는가

Normal map의 tangent-space 벡터는 다음처럼 tangent, bitangent, normal 축으로 변환됩니다.

nworld=Tnt.x+Bnt.y+Nnt.zn_{world} = T n_t.x + B n_t.y + N n_t.z

엔진이 DirectX 규격을 사용하고 ComfyUI 결과가 OpenGL 규격이라면 green 채널을 반전해야 합니다. 이 변환은 이미지 뷰어에서 판단하지 말고 실제 엔진의 중립 머티리얼에서 확인해야 합니다.

PBR 맵을 채널별로 확인하는 이미지와 색 공간 검증 화면

Blender CLI에서 지오메트리 기반 최종 베이킹을 연결하는 방법

ComfyUI에서 생성한 height 또는 normal 결과를 그대로 최종 게임 맵으로 사용하기보다 하이폴리와 로우폴리 메시가 있는 자산은 Blender에서 한 번 더 베이크하는 편이 안전합니다.

blender -b props.blend --python bake_pbr.py -- \
  --input-dir ./generated_pbr \
  --output-dir ./baked

Blender 스크립트에서는 다음 순서가 일반적입니다.

  1. 하이폴리와 로우폴리 오브젝트를 이름 규칙으로 매칭합니다.
  2. 로우폴리 UV와 이미지 크기를 준비합니다.
  3. Cycles 베이크 엔진과 NORMAL, AO, ROUGHNESS 등의 베이크 타입을 설정합니다.
  4. margin, max_ray_distance, cage_extrusion을 자산 크기에 맞춰 조정합니다.
  5. PNG 또는 EXR로 저장하고 파일명에 맵 타입을 포함합니다.
  6. 생성된 파일을 머티리얼에 연결해 seam과 투영 오류를 검사합니다.

중요한 점은 ComfyUI의 height 결과가 실제 고해상도 메시의 표면 높이를 보장하지 않는다는 것입니다. 실제 메시 디테일이 필요하면 ComfyUI 결과를 참고용 height나 마스크로 사용하고 최종 normal과 AO는 메시에서 베이크해야 합니다.

품질 검증 체크리스트

자동 배치가 끝났다고 바로 엔진에 넣지 말고 다음 항목을 검사합니다.

  • 모든 자산에 필수 맵이 존재하는가
  • 각 맵의 해상도와 종횡비가 동일한가
  • Base Color에 그림자와 하이라이트가 남아 있지 않은가
  • Roughness와 Metallic이 의도하지 않은 색상 보정을 거치지 않았는가
  • Normal map의 green 채널 방향이 엔진 규격과 일치하는가
  • Height와 AO에 과도한 대비나 검정 클리핑이 없는가
  • UV 경계에서 seam이 보이지 않는가
  • 반복 타일링 시 경계가 연결되는가
  • 파일명과 폴더 구조가 엔진 임포트 규칙에 맞는가
  • 동일 seed로 재실행했을 때 허용 범위 안에서 재현되는가

자동 검증을 추가하려면 Pillow나 OpenImageIO로 해상도, 채널 수, 최소·최대값, 알파 채널 유무를 검사할 수 있습니다. 시각 품질은 수치만으로 판단하기 어려우므로 최종적으로는 Blender 머티리얼 미리보기나 게임 엔진의 실제 라이팅에서 확인해야 합니다.

자주 묻는 질문 FAQ

ComfyUI만으로 정확한 게임용 normal map을 만들 수 있는가?

가능하지만 항상 정확한 것은 아닙니다. 이미지 기반 normal map은 원본 이미지의 조명과 재질 정보를 분리하지 못할 수 있으므로 메시 기반 베이크 또는 수동 검수가 필요합니다.

ComfyUI 배치 처리에서 자산마다 다른 프롬프트를 사용할 수 있는가?

가능합니다. 워크플로우의 CLIPTextEncode 노드 ID를 매핑하고 자산명, 재질 분류, 마스크 정보를 기반으로 text 입력을 교체하면 됩니다. 다만 프롬프트 변경은 결과 재현성에 영향을 주므로 입력 프롬프트도 로그에 저장해야 합니다.

출력이 생성되었는데 파일이 누락되는 이유는 무엇인가?

SaveImage 노드의 ID가 스크립트와 다르거나 해당 노드가 실행 그래프에서 연결되지 않았을 가능성이 큽니다. API Format JSON의 class_type, inputs, 연결된 출력 노드를 확인하고 history 응답에 해당 노드의 images 배열이 있는지 검사하세요.

정리

ComfyUI 워크플로우를 CLI와 연결하면 반복적인 텍스처 투입, 시드 설정, 출력 파일 저장, 실패 재처리를 자동화할 수 있습니다. 핵심은 API Format JSON을 안정적인 템플릿으로 관리하고 입력 이미지, 노드 ID, 맵별 출력 경로를 명확한 계약으로 정의하는 것입니다.

다만 이미지 기반 PBR 맵 생성과 지오메트리 기반 베이킹은 서로 다른 작업입니다. ComfyUI는 대량 생성과 전처리에 활용하고 Blender나 전용 베이커에서 메시 기반 결과와 색 공간을 검증하면 아트·테크 아트 파이프라인의 재현성과 품질을 함께 확보할 수 있습니다.