← All Posts

UE5 Interchange 프레임워크 커스텀 트랜슬레이터 개발기: PMX 포맷 임포트

Deep Dive
UE5 Interchange 프레임워크 커스텀 트랜슬레이터 개발기: PMX 포맷 임포트

들어가며

이전 프로젝트에서 Interchange를 커스텀하여 사내 에셋 파이프라인을 구축한 경험이 있지만, 보안 상 해당 작업 과정을 공개하기는 어렵기 때문에 Interchange 복기할 겸 공개 가능한 주제로 PMX(MikuMikuDance 모델) 포맷의 Interchange Translator를 처음부터 구현하고, 그 과정을 정리하여 공유하고자 한다.

다루는 내용:

  • Interchange 프레임워크의 아키텍처와 임포트 파이프라인
  • 커스텀 트랜슬레이터를 설계하고 구현하는 과정
  • PMX라는 바이너리 포맷을 SkeletalMesh로 변환하면서 마주친 기술적 판단들
  • 빌드 에러, 런타임 크래시 등 실제 삽질과 해결

1. Interchange 프레임워크란

Interchange Unreal Engine 5의 외부 에셋 임포트를 위한 범용 프레임워크이다.
3D 모델(FBX, glTF, OBJ, USD)뿐 아니라 텍스처(PNG, JPG, DDS, PSD 등), 오디오(WAV, MP3, FLAC 등), 머티리얼(MaterialX)까지 — 언리얼에서 파일을 임포트할 때 거치는 거의 모든 경로가 Interchange를 통한다.
Blueprint 혹은 Unreal C++를 사용하여 프로젝트에서 원하는대로 임포트 파이프라인을 구축할 수 있다.

1.1 왜 Interchange인가

UE5 이전에는 포맷마다 별도의 UFactory를 만들어야 했다. FBX는 UFbxFactory, OBJ는 UReimportFbxStaticMeshFactory(이름과 달리 OBJ도 처리), 텍스처는 UTextureFactory — 각 Factory가 파싱부터 에셋 생성까지 독자적으로 구현했기 때문에 중복 코드가 많고 동작도 일관되지 않았다.

특히 프로덕션 파이프라인의 중심이었던 FBX가 대표적인 예다. UFbxFactoryFactoryCreateFile() 하나(약 1,800줄)에 타입 감지부터 에셋 생성까지 몰려 있어서, 임포트 동작을 커스텀하거나 새 포맷을 추가하기가 어려웠다.

Interchange는 이 문제를 역할별로 분리하고, 포맷에 관계없이 하나의 파이프라인을 공유하는 프레임워크다:

flowchart LR
    A[소스 파일] --> B[Translator]
    B --> C[노드 그래프]
    C --> D[Pipeline]
    D --> C
    C --> E[Factory]
    E --> F[UE 에셋]

    subgraph C[노드 그래프]
        C1[Translated Node<br>메시·본·머티리얼·텍스처]
        C2[Factory Node<br>어떤 에셋을 만들지 지시]
    end

그리고 각 단계는 독립적으로 움직인다:

  • Translator — 소스 파일을 읽어 "이 파일에 무엇이 들어있는지"를 노드 그래프로 표현한다. 새 포맷을 지원하려면 이것만 만들면 된다.
  • Pipeline — 노드 그래프를 가공하고, 어떤 UE 에셋을 만들지 결정한다. 임포트 동작을 바꾸고 싶으면 이 단계를 교체하거나 확장한다. 예를 들어 특정 머티리얼을
    커스텀 셰이더로 대체하거나, 특정 본을 제외하는 등의 처리가 여기서 이루어진다.
  • Factory — Pipeline의 지시에 따라 실제 UE 에셋(SkeletalMesh, Material, Texture 등)을 생성한다. 엔진에 이미 구현되어 있어서 커스텀 트랜슬레이터 개발자가 직접 만들 일은 거의 없다.

특히 새 포맷을 지원할 때 이점이 크다. Translator 하나만 구현하고 지원 확장자를 등록하면, 에디터의 드래그 앤 드롭·임포트 다이얼로그·에셋 브라우저가 해당 포맷을 자동으로 인식한다. 과거 UFactory 방식처럼 에디터 코드를 직접 수정할 필요가 없다.

1.2 노드 그래프 — Translator와 Factory의 중간 언어

Interchange의 핵심 아이디어는 노드 그래프다. UInterchangeBaseNodeContainer라는 플랫 맵(TMap<FString, Node*>)에 모든 노드가 들어간다.

노드는 두 종류로 나뉜다:

  • Translated Node — Translator가 생성. "이 파일에 메시가 하나 있고, 본이 100개 있고, 머티리얼이 5개 있다"는 정보를 선언적으로 표현한다.
    • 예: UInterchangeSceneNode, UInterchangeMeshNode, UInterchangeShaderGraphNode, UInterchangeTexture2DNode
  • Factory Node — Pipeline이 생성. "이 Translated Node들을 조합해서 SkeletalMesh 에셋을 하나 만들어라"는 지시.
    • 예: UInterchangeSkeletalMeshFactoryNode, UInterchangeMaterialFactoryNode

Translated Node를 처음 접하면 종류가 많아서 헷갈리는데, 역할은 단순하다. 파일 하나를 임포트할 때 만들어지는 노드 구조를 예로 보면 감이 잡힌다.

plain text
씬 노드 (파일 — 최상위)                           ← TranslatedScene
├── 씬 노드 (그룹1)                               ← TranslatedScene
│     └──→ 메시 노드 (그룹1의 메시 데이터)          ← TranslatedAsset
│              └──→ 머티리얼 노드 X                 ← TranslatedAsset
├── 씬 노드 (그룹2)                               ← TranslatedScene
│     └──→ 메시 노드 (그룹2의 메시 데이터)          ← TranslatedAsset
│              └──→ 머티리얼 노드 Y                 ← TranslatedAsset

모두 UInterchangeSceneNode이며, 부모 유무로 계층이 결정된다. 최상위 씬 노드는 파일 자체를 대표하고, 그 아래 씬 노드들이 파일 내부의 구조(OBJ라면 메시 그룹, 스켈레탈이라면 본 계층)를 표현한다.

  • UInterchangeSceneNode — 파일의 계층 구조를 표현한다. SetupNode()에서 부모 UID를 지정하지 않으면 parent, 지정하면 child가 된다. parent는 파일 자체를 대표하고, child는 파일 내부의 구조(OBJ라면 메시 그룹, 스켈레탈이라면 본 계층)를 표현한다.
  • UInterchangeMeshNode — 메시 에셋 데이터를 표현한다. 버텍스 수, 폴리곤 수 같은 메타 정보와 페이로드 키를 담는다.
  • UInterchangeMaterialInstanceNode — 머티리얼 에셋을 표현한다. 메시 노드가 "이 슬롯에 이 머티리얼을 써라"고 참조한다.

Translator 입장에서 중요한 것: 노드 그래프만 올바르게 구성하면 나머지는 엔진이 알아서 처리한다. Pipeline은 GenericAssetsPipeline이 기본으로 돌고, Factory는 SkeletalMesh/Material/Texture용이 이미 있다. 커스텀 트랜슬레이터 개발자가 Factory를 직접 만들 일은 거의 없다.

1.3 페이로드 — 지연 로딩 패턴

Payload(페이로드)는 원래 운송/항공에서 "운반체가 실어 나르는 실제 화물"을 뜻하는 용어다. 로켓의 payload는 로켓이 아니라 로켓이 실어 나르는 위성이고, 네트워크 패킷의 payload는 헤더가 아니라 실제 전송하려는 데이터다. Interchange에서도 같은 맥락으로, 노드는 메타 정보만 담고 실제 무거운 데이터는 payload로 따로 전달한다.

Translator는 Translate()에서 노드 그래프만 만들고, 실제 메시 데이터(FMeshDescription)나 텍스처 픽셀은 아직 넘기지 않는다. 이 데이터는 나중에 Factory가 필요할 때 Translator의 페이로드 인터페이스를 호출해서 가져간다.

sequenceDiagram
    participant T as Translator
    participant N as 노드 그래프
    participant D as 임포트 다이얼로그
    participant P as Pipeline
    participant F as Factory

    T->>N: Translate() — 노드 등록<br>PayloadKey = 'MyMesh' (선언만)
    N->>D: 노드 그래프 기반으로<br>에셋 목록 표시
    D->>P: Import 버튼 클릭
    P->>N: 노드 그래프 처리<br>Factory Node 생성
    F->>T: GetMeshPayloadData('MyMesh')<br>실제 FMeshDescription 요청
    T-->>F: FMeshDescription 반환
    F->>F: UE 에셋 생성

이 지연 로딩 덕분에 임포트 다이얼로그에서 사용자가 선택하지 않은 에셋은 실제로 로드하지 않아 메모리와 시간을 절약할 수 있다.


2. PMX 포맷 개요

PMX(Polygon Model eXtended)
:
MikuMikuDance(MMD)에서 사용하는 3D 모델 포맷이다. 바이너리 포맷이고, 버전 2.0/2.1이 주로 쓰인다.

PMX는 바이너리 포맷이다. OBJ처럼 텍스트로 저장하는 포맷은 메모장으로 열어서 v 1.0 2.0 3.0 같은 내용을 직접 읽을 수 있지만, 바이너리 포맷은 데이터를 바이트 단위로 직접 저장한다. 예를 들어 버텍스 좌표 1.0을 문자열 "1.0" 대신 4바이트 float(0x3F800000)로 기록한다. 파일 크기가 작고 파싱이 빠른 대신, 사람이 직접 읽을 수 없고 전용 파서가 필요하다.

2.1 PMX 데이터 구성

요소설명
버텍스Position, Normal, UV + 본 웨이트(BDEF1/2/4, SDEF, QDEF)
인덱스삼각형 인덱스 (1/2/4바이트 가변 크기)
텍스처 테이블상대 경로 문자열 배열 (실제 이미지 파일은 외부)
머티리얼Diffuse, Specular, Ambient, Toon 텍스처, SphereMap, 엣지
IK, 부여(Grant/Inherit) 본, 물리 후 변형 등 고유 계층
모프버텍스/UV/본/머티리얼/그룹 모프
리지드바디 & 조인트Bullet Physics 기반 물리 설정

본 웨이트 방식 (BDEF1/2/4, SDEF, QDEF) — 버텍스가 어떤 본에 얼마나 영향받는지를 계산하는 방식이다.

  • BDEF1 — 본 1개에 100% 영향. 웨이트 값 없이 본 인덱스만 저장한다.
  • BDEF2 — 본 2개. 웨이트 하나만 저장하면 나머지는 1.0 - 웨이트로 계산한다.
  • BDEF4 — 본 4개에 웨이트 4개를 각각 저장한다. UE의 스키닝과 동일한 방식이다.
  • SDEF — 본 2개에 보간 파라미터(C, R0, R1 벡터)가 추가된다. 어깨·관절처럼 꺾이는 부분에서 메시가 찌그러지지 않도록 하는 MMD 고유 방식이다.
  • QDEF — BDEF4에 쿼터니언 보간을 추가한 방식이나, 실제로 거의 쓰이지 않는다.

UE로 임포트할 때는 결국 본 인덱스 + 웨이트 배열로 변환해야 하므로, SDEF의 보간 파라미터 같은 고유 데이터는 버리거나 근사 처리해야 한다.

인덱스 크기가 가변인 이유 — 인덱스는 버텍스 번호를 가리키는 값이다. 버텍스가 255개 이하면 1바이트(uint8), 65,535개 이하면 2바이트(uint16), 그 이상이면 4바이트(uint32)로 충분하다. PMX는 파일 헤더에서 인덱스 크기를 지정하고 모든 인덱스가 해당 크기를 사용한다. 버텍스가 100개뿐인 모델에 4바이트씩 쓰면 낭비이므로, 바이너리 포맷의 이점을 살린 최적화다.


3. 설계

3.1 참고 구현 — OBJ·FBX 트랜슬레이터

엔진에는 OBJ, FBX, glTF 등 여러 트랜슬레이터가 포함되어 있다. 이 중 OBJ 트랜슬레이터(UInterchangeOBJTranslator)를 참고 구현으로 선택했다. FBX 트랜슬레이터는 메시, 스켈레톤, 애니메이션, 씬 계층, 라이트, 카메라 등 거의 모든 에셋 타입을 처리하기 때문에 코드량이 방대하다. OBJ는 메시+머티리얼만 다루기 때문에 Translate() 하나에서 Interchange 트랜슬레이터의 핵심 패턴을 바로 파악할 수 있다. 뼈대를 먼저 이해한 뒤 SkeletalMesh 로직을 얹는 게 FBX 전체를 읽는 것보다 효율적이었다.

OBJ 트랜슬레이터가 사용하는 핵심 패턴은 다음과 같다:

  • UInterchangeTranslatorBase 상속 — 모든 Interchange 트랜슬레이터의 베이스 클래스다. 이를 상속해야 Translate(), GetSupportedFormats() 같은 인터페이스를 구현할 수 있고, 엔진이 트랜슬레이터로 인식한다.
  • Pimpl 패턴으로 내부 데이터 은닉 — 헤더(Public)에는 TPimplPtr<FOBJData> 포인터만 선언하고, 파싱 데이터 구조체의 실제 정의는 .cpp(Private)에 둔다. 외부에서 include할 때 내부 구현을 몰라도 되고, 구조체를 수정해도 .cpp만 재컴파일하면 된다.
  • Translate()에서 노드 생성 — 소스 파일을 파싱한 뒤, UInterchangeSceneNode·UInterchangeMeshNode 등을 SetupNode()로 노드 그래프에 등록한다.
  • 페이로드 인터페이스로 지연 로딩IInterchangeMeshPayloadInterface를 구현하여 GetMeshPayloadData()를 제공한다. Translate()에서는 키만 등록하고, Factory가 요청할 때 실제 FMeshDescription을 반환한다.

이 뼈대를 기반으로 SkeletalMesh 특화 로직을 추가했다. OBJ는 StaticMesh(본 없는 메시)만 다루기 때문에, 본·스키닝·모프타겟 같은 SkeletalMesh 로직은 FBX 트랜슬레이터를 참고했다. 실제 PMX 트랜슬레이터의 코드를 보면 FBX와 동일한 패턴이 확인된다:

  • 본 계층UInterchangeSceneNodeAddSpecializedType(FSceneNodeStaticData::GetJointSpecializeTypeString())으로 Joint를 등록하는 방식이 FBX와 동일하다.
  • 스킨 웨이트FSkeletalMeshAttributes로 등록하고 FSkinWeightsVertexAttributesRef로 버텍스별 본 웨이트를 설정하는 흐름이 FBX와 동일하다.
  • 모프타겟SetMorphTarget(true)SetMorphTargetName()SetMorphTargetDependencyUid()로 메인 메시에 연결하는 패턴이 FBX와 동일하다.
  • 머티리얼 — FBX 트랜슬레이터가 사용하는 FBXLegacyPhongSurfaceMaterial을 부모 머티리얼로 그대로 사용했다.

정리하면, 트랜슬레이터의 구조와 뼈대는 OBJ에서, SkeletalMesh 특화 로직은 FBX에서 가져온 셈이다.

3.2 플러그인 구조

프로젝트 플러그인으로 개발했다. 엔진 코드를 수정하지 않고, 프로젝트의 Plugins/ 폴더에 독립 모듈로 존재한다.

  graph TD
    A["📁 씬 노드 (파일 — 최상위)<br/><small>TranslatedScene</small>"]
    A --> B["📂 씬 노드 (그룹1)<br/><small>TranslatedScene</small>"]
    A --> C["📂 씬 노드 (그룹2)<br/><small>TranslatedScene</small>"]
    B --> D["🔷 메시 노드 (그룹1)<br/><small>TranslatedAsset</small>"]
    D --> E["🎨 머티리얼 노드 X<br/><small>TranslatedAsset</small>"]
    C --> F["🔷 메시 노드 (그룹2)<br/><small>TranslatedAsset</small>"]
    F --> G["🎨 머티리얼 노드 Y<br/><small>TranslatedAsset</small>"]

3.3 클래스 설계

파일 위치: InterchangePMX/Source/Public/InterchangePMXTranslator.h

c++
UCLASS(BlueprintType)
class UInterchangePMXTranslator : public UInterchangeTranslatorBase
    , public IInterchangeMeshPayloadInterface    // 메시 페이로드 인터페이스
    , public IInterchangeTexturePayloadInterface  // 텍스처 페이로드 인터페이스
{
    GENERATED_BODY()

public:
    // 확장자 등록 — "pmx;PMX Model File"을 반환하여 에디터가 .pmx 파일을 이 트랜슬레이터에 연결
    virtual TArray<FString> GetSupportedFormats() const override;

    // 이 트랜슬레이터가 다루는 에셋 타입 선언 — Meshes | Materials
    virtual EInterchangeTranslatorAssetType GetSupportedAssetTypes() const override;

    // 임포트 방식 선언 — Assets(컨텐츠 브라우저에 에셋 생성)
    virtual EInterchangeTranslatorType GetTranslatorType() const override;

    // 노드 그래프 생성 — PMX 파일을 파싱하고, 씬/메시/머티리얼/텍스처 노드를 등록
    virtual bool Translate(UInterchangeBaseNodeContainer& BaseNodeContainer) const override;

    // 메시 페이로드 — Factory가 요청할 때 FMeshDescription(버텍스, 인덱스, 스킨 웨이트 등)을 반환
    virtual TOptional<UE::Interchange::FMeshPayloadData> GetMeshPayloadData(...) const override;

    // 텍스처 페이로드 — Factory가 요청할 때 텍스처 이미지 데이터를 반환
    virtual TOptional<UE::Interchange::FImportImage> GetTexturePayloadData(...) const override;

private:
    TPimplPtr<FPMXData> PMXDataPtr;  // Pimpl — 파싱 결과를 보관, 실제 정의는 .cpp에
};

Factory가 실제 에셋을 생성할 때, 트랜슬레이터에게 데이터를 요청한다. 이때 어떤 데이터를 요청할 수 있는지는 트랜슬레이터에 구현된 페이로드 인터페이스에 의해 결정된다. PMX 파일 안에는 텍스처 경로가 포함되어 있으므로, 메시와 텍스처를 한 번에 임포트할 수 있도록 트랜슬레이터에 IInterchangeMeshPayloadInterfaceIInterchangeTexturePayloadInterface를 모두 구현했다. 이로써 Factory가 메시 데이터와 텍스처 데이터를 둘 다 요청할 수 있다. 반면 OBJ 트랜슬레이터는 IInterchangeMeshPayloadInterface만 구현되어 있다.

3.4 핵심 설계 판단

결정선택이유
메시 타입SkeletalMeshPMX는 본/스키닝이 핵심. StaticMesh로는 의미가 없다
파서 분리FPMXData 별도 클래스바이너리 파싱과 노드 그래프 생성을 분리. 테스트와 유지보수가 용이
SupportedAssetTypesMeshes \| MaterialsTextures 플래그를 포함하면 BaseEngine.ini의 기본 설정에 의해 임포트 다이얼로그가 비활성화된다. 텍스처는 머티리얼 종속성으로 자동 임포트되므로 플래그 없이도 동작한다. FBX/glTF/OBJ 전부 동일한 패턴
SDEF 스키닝BDEF2로 폴백UE에 SDEF 네이티브 지원이 없어서, 보간 파라미터를 버리고 BDEF2 방식으로 변환했다
VMD 애니메이션별도 트랜슬레이터PMX=모델, VMD=애니메이션. 포맷 자체가 분리되어 있으므로 트랜슬레이터도 분리
물리(RigidBody/Joint)보류PhysicsAsset 매핑은 커스텀 파이프라인이 필요. 애니메이션 시스템 선행 필요

4. 구현 — Translate()

Translate()는 트랜슬레이터의 핵심이다. PMX 파일을 읽어 노드 그래프를 구성한다.

4.1 전체 흐름

flowchart TD
    A[① PMX 바이너리 파싱] --> B[② Parent UInterchangeSceneNode 생성]
    B --> C[③ 텍스처 노드 생성<br>PMX 텍스처 테이블 → Texture2DNode]
    B --> D[④ 머티리얼 노드 생성<br>UInterchangeMaterialInstanceNode<br>FBXLegacyPhongSurfaceMaterial 부모]
    B --> E[⑤ 스켈레톤 노드 생성<br>가상 Root + 본 계층 → Joint SceneNode]
    B --> F[⑥ 메시 노드 생성<br>MeshNode, SKELETAL 페이로드]
    B --> G[⑦ 액터 노드 생성<br>메시 인스턴스 씬 노드]
    F --> H[⑧ 모프 타겟 등록]

4.2 스켈레톤 — 가상 Root와 본 계층

PMX의 본 구조를 Interchange 노드로 변환하는 부분이 가장 까다로웠다.

PMX의 각 본은 ParentIndex로 부모 본을 가리킨다. 예를 들어 ParentIndex = 2면 본[2]가 부모다. PMX 스펙에서 Bone 인덱스의 nil 값은 -1로 정의되어 있으며, ParentIndex = -1이면 부모가 없는 최상위 본을 의미한다. PMX 스펙은 이 최상위 본이 하나여야 한다는 제약이 없어서, ParentIndex가 -1인 본이 여러 개일 수 있다. 대부분의 모델은 "全ての親(전체의 부모)" 같은 단일 최상위 본을 가지지만, 보장되지는 않는다. 반면 UE의 SkeletalMesh는 단일 루트 본을 요구한다. 그래서 가상의 Root 본을 하나 만들고, PMX에서 부모가 없는 본들을 전부 이 Root 아래에 붙였다.

c++
for (int32 i = 0; i < PMXData.Bones.Num(); ++i)
{
    const FPMXBone& Bone = PMXData.Bones[i];

    // ParentIndex >= 0이면 해당 본을 부모로, -1이면 가상 Root를 부모로 연결
    FString ParentUid = (Bone.ParentIndex >= 0)
        ? TEXT("\\\\Joint\\\\") + FString::FromInt(Bone.ParentIndex)
        : RootJointUid;

    // PMX는 본 위치를 월드 좌표로 저장하지만,
    // Interchange 노드에는 부모 기준 로컬 좌표를 넣어야 한다.
    // 로컬 위치 = 자기 월드 위치 - 부모 월드 위치
    FVector BoneWorldPos = ConvertPMXPosition(Bone.Position);
    FVector ParentWorldPos = (Bone.ParentIndex >= 0)
        ? ConvertPMXPosition(PMXData.Bones[Bone.ParentIndex].Position)
        : FVector::ZeroVector;
    FTransform LocalTransform(FQuat::Identity, BoneWorldPos - ParentWorldPos);

    BoneNode->SetCustomLocalTransform(&BaseNodeContainer, LocalTransform);
    BoneNode->SetCustomBindPoseLocalTransform(&BaseNodeContainer, LocalTransform);
}

4.3 텍스처 & 머티리얼 — 종속성 체인

PMX의 텍스처는 파일 내에 직접 포함되지 않고, 텍스처 경로 테이블(상대 경로 문자열 배열)로 관리된다. 실제 이미지 파일은 PMX 파일과 같은 폴더에 외부 파일로 존재한다.

노드 그래프에서 메시는 머티리얼을, 머티리얼은 텍스처를 참조한다. Factory는 이 종속성을 역순으로 따라가며 텍스처 → 머티리얼 → 메시 순서로 에셋을 생성한다.

flowchart LR
    A[① UInterchangeTexture2DNode<br>텍스처] --> B[② UInterchangeMaterialInstanceNode<br>머티리얼] --> C[③ UInterchangeMeshNode<br>메시]

    C -.->|SetSlotMaterialDependencyUid| B
    B -.->|AddTextureParameterValue| A

    L1[──] --> L2[에셋 생성 순서]
    L3[- - -] -.-> L4[함수 참조]

    style L1 fill:none,stroke:none
    style L2 fill:none,stroke:none
    style L3 fill:none,stroke:none
    style L4 fill:none,stroke:none

    linkStyle 0,1 stroke:#9E9E9E
    linkStyle 2,3 stroke:#FF5722
    linkStyle 4 stroke:#9E9E9E
    linkStyle 5 stroke:#FF5722

텍스처 노드는 PMX 텍스처 테이블의 경로마다 하나씩 생성하고, SetPayLoadKey()에 절대 경로를 넣는다. Factory가 나중에 이 경로로 실제 이미지 파일을 로드한다.

c++
// 텍스처 노드 생성
UInterchangeTexture2DNode* TexNode = NewObject<UInterchangeTexture2DNode>(&BaseNodeContainer);
BaseNodeContainer.SetupNode(TexNode, TexNodeUid,
    FPaths::GetBaseFilename(TexRelPath),
    EInterchangeNodeContainerType::TranslatedAsset);
TexNode->SetPayLoadKey(TexAbsPath);  // 텍스처 파일의 절대 경로

머티리얼 노드는 FBXLegacyPhongSurfaceMaterial을 부모로 설정하고, AddTextureParameterValue()로 텍스처 노드를 연결한다. 다만 이 부모 머티리얼은 임시 선택이다. PMX는 본래 툰 셰이딩 기반이므로 전용 머티리얼이 필요하지만, 우선 임포트 시스템 구현에 집중하기 위해 FBX 트랜슬레이터가 사용하는 기존 Phong 머티리얼을 그대로 가져다 썼다. 메시 노드는 SetSlotMaterialDependencyUid()로 머티리얼 슬롯을 지정한다.

c++
// 머티리얼 노드 — 텍스처 연결
MatNode->AddTextureParameterValue(TEXT("DiffuseColorMap"), TexUid);
MatNode->AddVectorParameterValue(TEXT("DiffuseColor"),
    FLinearColor(Mat.DiffuseColor.R, Mat.DiffuseColor.G, Mat.DiffuseColor.B));

// 메시 노드 — 머티리얼 슬롯 연결
MeshNode->SetSlotMaterialDependencyUid(SlotName, MaterialUid);

이 종속성을 올바르게 연결하면, Translator는 관계만 선언하고 나머지는 Pipeline과 Factory가 알아서 처리한다.

4.4 모프 타겟 — PMX 페이셜을 UE에서 쓰기까지

PMX 모델에는 표정용 버텍스 모프가 포함되어 있다(눈 깜빡임, 입 모양 등). 이걸 UE의 MorphTarget으로 매핑하면 에디터에서 슬라이더로 조작할 수 있다.

Interchange에서의 처리 흐름:

Translate()에서는 모프타겟용 UInterchangeMeshNode를 생성하고, 메인 메시에 종속성을 연결한다. 실제 버텍스 데이터는 페이로드로 지연 로딩된다.

c++
// 모프타겟용 메시 노드 생성
UInterchangeMeshNode* MorphMeshNode = NewObject<UInterchangeMeshNode>(&BaseNodeContainer);
BaseNodeContainer.SetupNode(MorphMeshNode, MorphMeshUid, MorphName,
    EInterchangeNodeContainerType::TranslatedAsset);

// 모프타겟임을 선언하고 이름 지정
MorphMeshNode->SetPayLoadKey(PayloadKey, EInterchangeMeshPayLoadType::MORPHTARGET);
MorphMeshNode->SetMorphTarget(true);
MorphMeshNode->SetMorphTargetName(MorphName);

// 메인 메시에 종속성 연결
MeshNode->SetMorphTargetDependencyUid(MorphMeshUid);

Factory가 에셋을 생성할 때 GetMeshPayloadData()를 호출하면, FMeshDescription에 모프별 버텍스 위치를 채워서 반환한다.

여기서 주의할 점은, Interchange의 Factory가 모프 위치를 절대 좌표로 해석한다는 것이다:

plain text
delta = morphTargetPos - baseMeshPos

PMX의 모프 데이터는 "영향 받는 버텍스의 오프셋"만 저장하고 있으므로, 오프셋을 그대로 넣으면 안 된다. 영향 없는 버텍스가 ZeroVector가 되어 0 - basePos로 계산되고, 모든 버텍스가 원점으로 끌려간다. 따라서 모든 버텍스를 베이스 메시 위치로 초기화한 뒤, 영향 받는 버텍스에만 오프셋을 더해야 한다.

c++
// 모든 버텍스를 베이스 위치로 초기화
for (int32 i = 0; i < VertexCount; ++i)
    MorphPositions[i] = ConvertPMXPosition(BaseVertices[i].Position);

// 영향 받는 버텍스에만 오프셋 추가
for (const auto& [VertexIndex, Offset] : Morph.VertexOffsets)
    MorphPositions[VertexIndex] += ConvertPMXPosition(Offset);
모프 타겟 테스트 1meants.dev
모프 타겟 테스트 1
모프 타겟 테스트 2meants.dev
모프 타겟 테스트 2

PMX에는 버텍스 모프 외에도 본 모프, 머티리얼 모프, 그룹 모프 등이 있지만, Interchange의 MorphTarget은 버텍스 오프셋만 지원한다. 나머지 모프 타입은 커스텀 파이프라인에서 별도 처리가 필요하다.


5. 구현 — 바이너리 파서

5.1 PMX 바이너리 구조

PMX는 헤더부터 각 섹션을 순서대로 읽어야 하는 바이너리 포맷이다. 특정 섹션으로 바로 건너뛸 수 없고,
앞 섹션의 크기를 계산해야 다음 위치를 알 수 있다.

flowchart LR
    A[헤더<br>매직넘버 · 버전 · 글로벌 설정] --> B[모델 정보<br>이름 · 코멘트]
    B --> C[지오메트리<br>버텍스 · 인덱스 · 텍스처]
    C --> D[머티리얼 · 본 · 모프]
    D --> E[물리<br>리지드바디 · 조인트]

헤더의 글로벌 설정에는 "본 인덱스를 몇 바이트로 읽을지", "텍스처 인덱스를 몇 바이트로 읽을지" 같은 인덱스 크기가 종류별로 정의되어 있다(1/2/4바이트).
예를 들어 본이 256개 이하인 모델은 본 인덱스를 1바이트로 저장하고, 그 이상이면 2바이트나 4바이트를 쓴다. 이 설정은 파일 전체에 걸쳐 적용되기 때문에, 헤더를 먼저 읽어 인덱스 크기를 파악해야 이후 섹션을 올바르게 파싱할 수 있다.

5.2 인덱스 크기별 읽기

5.1에서 설명한 것처럼, PMX의 인덱스는 모델에 따라 1/2/4바이트로 크기가 달라진다.
하나의 함수에서 크기별로 분기해서 읽어야 한다.

c++
int32 FPMXParser::ReadIndex(FMemoryReader& Ar, int32 IndexSize)
{
    switch (IndexSize)
    {
    case 1: { int8  V; Ar << V; return (V == -1) ? -1 : V; }
    case 2: { int16 V; Ar << V; return (V == -1) ? -1 : V; }
    case 4: { int32 V; Ar << V; return V; }
    }
    return -1;
}

여기서 주의할 점은 부호 있는 타입(int8, int16)으로 읽어야 한다는 것이다.
PMX에서 -1은 "참조 없음"을 의미하는데(예: 부모 본이 없는 루트 본), 1바이트에서는 0xFF, 2바이트에서는 0xFFFF가 -1에 해당한다. 부호 없는 타입으로 읽으면 이 값이 255나 65535로 해석되어 존재하지 않는 인덱스를 참조하게 된다.

5.3 스키닝 웨이트 — 다양한 디포메이션 타입

스키닝이란 메시의 각 버텍스가 어떤 본에 얼마나 영향받는지를 정의하는 것이다. 예를 들어 팔꿈치 근처 버텍스는 상완 본과 하완 본에 50%씩 영향받아서, 팔을 구부리면 자연스럽게 따라 변형된다.

PMX는 버텍스마다 스키닝 방식이 다를 수 있다:

타입본 수설명
BDEF11하나의 본에 100% 종속 (손가락 끝 등 단순한 부위)
BDEF22두 본 사이를 웨이트로 블렌딩 (관절 부위)
BDEF44최대 4개 본의 영향을 받음 (어깨 등 복잡한 부위)
SDEF2BDEF2와 같지만, 관절 꺾임이 자연스러운 구면 보간 적용
QDEF4BDEF4와 같지만, 쿼터니언 기반 보간 적용 (PMX 2.1)

BDEF 계열은 UE의 스키닝과 동일한 방식이라 그대로 매핑하면 된다. 문제는 SDEF와 QDEF인데, 이 두 방식은 UE가 네이티브로 지원하지 않는 보간 알고리즘을 사용한다. SDEF는 본 웨이트 자체는 BDEF2와 동일하므로 추가 보간 파라미터(C, R0, R1)를 무시하고 BDEF2로 폴백했다. 관절부 변형이 약간 딱딱해지지만, 기본적인 스키닝은 정상 동작한다.


6. 좌표계 변환

임포트 파이프라인에서 가장 실수가 잦고 디버깅이 까다로운 부분이다.

6.1 PMX vs UE 좌표계

PMXUE
우측+X+X
전방-Z+Y
상단+Y+Z
핸드니스좌손좌손

둘 다 좌손 좌표계지만 축 배치가 다르다. 변환 함수:

c++
// PMX(X, Y, Z) → UE(X, -Z, Y)
FVector ConvertPMXPosition(const FVector3f& V)
{
    return FVector(V.X, -V.Z, V.Y) * PMXToUEScale;
}

// 노멀은 스케일 미적용
FVector ConvertPMXNormal(const FVector3f& V)
{
    return FVector(V.X, -V.Z, V.Y);
}

6.2 스케일

PMX의 1단위는 약 8cm에 해당한다 (일반적인 MMD 캐릭터 키 ~20단위 = 160cm). UE는 1단위 = 1cm이므로 8배 스케일을 적용했다.

c++
constexpr float PMXToUEScale = 8.0f;
좌: 단위 인지 전 애셋 임포트, 우: 수정 후 애셋 임포트meants.dev
좌: 단위 인지 전 애셋 임포트, 우: 수정 후 애셋 임포트

6.3 UV 좌표

PMX와 UE 모두 좌상단 원점의 UV 좌표를 사용하므로 변환이 필요 없다. glTF처럼 V축 반전이 필요한 포맷도 있는데, PMX는 그냥 그대로 쓰면 된다.

임포트 파이프라인에서 가장 실수가 잦고 디버깅이 까다로운 부분이다.

6.4 와인딩 순서

PMX는 DirectX 기반이라 시계방향(CW) 와인딩을 사용하고, UE는 반시계방향(CCW) 와인딩을 사용한다. 그대로 넣으면 일부 면의 노멀이 뒤집혀 보인다. 삼각형 인덱스 순서를 0,1,20,2,1로 반전해야 한다.

c++
// PMX CW → UE CCW: 폴리곤 생성 시 인덱스 순서를 반전
static const int32 WindingRemap[3] = { 0, 2, 1 };
for (int32 Corner = 0; Corner < 3; ++Corner)
{
    int32 PMXVertIdx = PMXDataPtr->Indices[BaseIdx + WindingRemap[Corner]];
    // ...
}

Blender의 mmd_tools 플러그인도 임포트 시 동일하게 와인딩을 뒤집는다.

블렌더에서 임포트한 pmx 파일, 붉은색으로 보이는 건 면이 뒤집힌 상태를 의미함meants.dev
블렌더에서 임포트한 pmx 파일, 붉은색으로 보이는 건 면이 뒤집힌 상태를 의미함
와인딩 수정 전meants.dev
와인딩 수정 전
와인딩 수정 후meants.dev
와인딩 수정 후

7. 결과

7.1 구현된 기능

기능상태
PMX 바이너리 파싱 (헤더, 버텍스, 인덱스, 텍스처, 머티리얼, 본, 모프)완료
SkeletalMesh 임포트 (메시 + 스켈레톤 + 스키닝)완료
머티리얼 & 텍스처 임포트완료
모프 타겟 (표정 블렌드셰이프)완료
좌표계 변환 & 스케일 보정완료

7.2 향후 확장 가능 영역

영역설명
VMD 애니메이션PMX=모델, VMD=애니메이션으로 포맷이 분리되어 있으므로 별도 트랜슬레이터가 필요하다
물리 (RigidBody/Joint)PMX 파서에서 이미 파싱하고 있으나, UE의 PhysicsAsset으로의 매핑은 미구현
Toon/SphereMap 머티리얼현재 FBXLegacyPhongSurfaceMaterial을 임시로 사용 중. PMX 본래의 툰 셰이딩을 위한 전용 머티리얼이 필요하다
SDEF 스키닝현재 BDEF2로 폴백 처리. UE 네이티브 지원이 없어 별도 구현이 필요하다
IK/부여 본파싱은 되어 있으나, UE에서의 활용 방법은 미검토

8. 정리

Interchange 프레임워크로 커스텀 트랜슬레이터를 만드는 것은 노드 그래프의 구조를 이해하는 것이 핵심이다. 어떤 노드를 만들어야 하고, 노드 간 관계(부모-자식, 종속성)를 어떻게 연결해야 하는지를 파악하면, 나머지는 엔진의 Pipeline과 Factory가 처리해준다.

PMX라는 하나의 포맷을 구현하는 과정에서 다음 영역들을 다뤘다:

  • 바이너리 파싱 — 바이너리 스트림을 직접 읽는 파서를 별도 클래스로 분리하는 설계
  • SkeletalMesh 파이프라인 — 본 계층, 스키닝 웨이트, 모프 타겟까지 Interchange의 스켈레탈 파이프라인을 전부 활용
  • 좌표계 변환 — 위치, 노멀, 본 트랜스폼, 와인딩 순서 각각에 변환 로직이 필요. 하나라도 틀리면 메시가 뒤틀린다
  • 에셋 종속성 체인 — 텍스처 → 머티리얼 → 메시의 종속성을 노드 그래프로 표현하고, Factory가 올바른 순서로 에셋을 생성하도록 구성
  • Interchange API 탐색 — 문서가 부족해서 엔진 소스(OBJ/FBX 트랜슬레이터)를 직접 읽어야 했다. 어떤 노드 클래스에 어떤 함수가 있는지, 어떤 순서로 호출해야 하는지를 기존 트랜슬레이터 코드에서 패턴을 찾아 적용했다

Interchange의 강점은 확장성이다. Translator 하나만 구현하고 확장자를 등록하면, 에디터가 해당 포맷을 자동으로 인식한다. 과거 UFbxFactory 방식처럼 에디터 코드를 직접 수정할 필요가 없다. 새로운 포맷의 에셋 파이프라인을 구축해야 한다면, Interchange는 충분히 검토할 가치가 있다.