모든 형식 페이지 ↓
유닛·스크립트·설정

3DO — 계층형 3D 모델

출처: coreprime/kbot @ 0ab97bf · GitHub에서 편집

.3do 파일은 3D 오브젝트의 트리입니다 — 정점, 프리미티브, 텍스처 참조가 부모/자식 계층으로 배치되어 있습니다. 게임의 모든 유닛, 형상이 있는 모든 정적 맵 지형지물, 3D 모델이 있는 모든 투사체가 이 형식을 씁니다. 3DO에는 애니메이션 데이터가 없습니다. 피스(piece)는 실행 중에 유닛의 COB 스크립트가 움직입니다.

Tip

직접 해 보세요.

# 3DO is not currently surfaced as a kbot CLI subcommand, but it's
# rendered live by the Asset Explorer:
kbot mount ~/games/totala --server      # opens the web UI
# then browse to objects3d/armcom.3do

CLI kbot mount 레퍼런스를 참고하세요.

Go에서 쓰기. formats/objects3d를 사용합니다.

import "github.com/coreprime/kbot/formats/objects3d"

model, _ := objects3d.LoadFromFile("objects3d/armcom.3do")
fmt.Println(model.Root.Name, "—",
    model.TotalVertices(), "verts,",
    model.TotalPrimitives(), "prims,",
    "textures:", model.Textures())
for _, obj := range model.AllObjects {
    fmt.Printf("  %s (%d verts)\n", obj.Name, len(obj.Vertices))
}

3DO가 이런 구조인 이유

.3do는 "비싼 행렬 계산 없이 스크립트가 유닛을 움직이게 하려면 어떻게 해야 할까?"라는 질문에 Cavedog이 내놓은 답이었고, 그 답은 계층 구조 + 피스별 변환입니다. 유닛은 이름 붙은 피스의 트리입니다.

armcom (root)
├── pelvis
│   ├── torso
│   │   ├── lupperarm
│   │   │   └── lforearm
│   │   │       └── lhand
│   │   ├── rupperarm  ...
│   │   └── head
│   ├── lthigh
│   │   └── lleg
│   └── rthigh
│       └── rleg
└── ...

COB 스크립트가 turn rupperarm to x-axis <30> speed <60>;이라고 하면, 엔진은 정확히 그 피스를 회전시키고 — 그 아래에 붙은 모든 것(팔뚝, 손, 포구)이 자동으로 따라 움직입니다. 현대적인 의미의 스킨 가중치나 본(bone)은 없습니다. 각 피스는 부모로부터 고정된 오프셋에 붙어 있는 강체 서브메시입니다.


디스크상 구조

각 오브젝트는 52바이트 헤더와 그 뒤에 이어지는 이름 붙은 배열 페이로드로 이루어집니다. 헤더에는 자식, 형제, 정점, 프리미티브의 오프셋이 들어 있습니다.

typedef struct {
    int32 VersionSignature;      // Always 1
    int32 NumberOfVertexes;
    int32 NumberOfPrimitives;
    int32 OffsetToSelectionPrim; // → primitive used as selection rectangle
                                 //   for the root; -1 for non-root objects
    int32 XFromParent;           // Fixed-point offset from parent piece
    int32 YFromParent;           //   (Y is up; the engine renders Y-up)
    int32 ZFromParent;
    int32 OffsetToObjectName;    // → NUL-terminated piece name string
    int32 Always0;
    int32 OffsetToVertexArray;
    int32 OffsetToPrimitiveArray;
    int32 OffsetToSiblingObject; // 0 = end of sibling chain
    int32 OffsetToChildObject;   // 0 = leaf piece
} Object;

정점은 int32 고정소수점 성분 세 개입니다.

typedef struct { int32 X, Y, Z; } Vertex;

고정소수점 규약은 정수부 = 게임 월드 단위, 1 단위보다 정밀한 값은 65536으로 나눈다입니다. Cavedog 유닛은 보통 지름이 수십에서 수백 월드 단위입니다.

프리미티브는 면(또는 점 하나, 또는 선 하나)입니다.

typedef struct {
    int32 ColorIndex;              // Palette index for solid-coloured faces (low byte used)
    int32 NumberOfVertexIndexes;   // 1 = point, 2 = line, 3 = tri, 4 = quad
    int32 Always0;
    int32 OffsetToVertexIndexArray; // → uint16[NumberOfVertexIndexes]
    int32 OffsetToTextureName;     // → NUL string, or 0 for untextured
    int32 Unknown1;                // Editor scratch
    int32 Unknown2;                // Editor scratch
    int32 IsColored;               // flag word: bit 0 set = solid-coloured
} Primitive;

OffsetToVertexIndexArray가 가리키는 정점 인덱스 배열은 (전역 풀이 아니라) 오브젝트의 지역 정점 배열에 대한 uint16 인덱스를 차례로 늘어놓은 목록입니다.


계층 순회

각 오브젝트 헤더는 첫 자식과 다음 형제를 가리킵니다. 이는 전형적인 "첫 자식 / 다음 형제" 트리 인코딩으로, 깊이 우선 순회를 쉽게 작성할 수 있습니다.

def walk(file, offset, depth=0):
    obj = read_object(file, offset)
    yield (depth, obj.name)
    if obj.child_offset:
        yield from walk(file, obj.child_offset, depth + 1)
    if obj.sibling_offset:
        yield from walk(file, obj.sibling_offset, depth)

루트 오브젝트는 파일 오프셋 0에 있습니다. 정식판 모델에서는 모두 루트의 SiblingOffset이 0이지만, 게임은 루트의 형제 체인도 따라갑니다: 루트의 각 형제(와 그 하위 트리)는 루트의 하위 트리 다음에 또 하나의 최상위 피스로 로드되며, 자기 정점 좌표 그대로 그려집니다 — 게임의 피스 변환은 루트에서 시작하므로 이들에는 피스 오프셋이 전혀 적용되지 않습니다. kbot의 모델 JSON은 이들을 rootSiblings로 담고, 에셋 탐색기는 그 개수를 세어 목록을 보여 줍니다. COB 스크립트의 모든 피스 이름은 이 트리의 피스 이름과 (대소문자 구분 없이) 일치해야 합니다. 일치하지 않으면 스크립트의 애니메이션이 아무 말 없이 아무 동작도 하지 않습니다.


텍스처 입히기

OffsetToTextureName은 anims/textures.gaf(또는 그에 해당하는 TA: Kingdoms 파일)의 시퀀스 이름을 담은 ASCII 문자열을 가리킵니다. 알아 둘 점이 몇 가지 있습니다.

  • UV 좌표가 없습니다. 엔진은 프리미티브의 정점 순서에서 UV를 만들어 냅니다. 텍스처를 "회전"하려면 프리미티브의 VertexIndexArray에서 정점 인덱스의 순서를 바꿉니다. 원조 3do Builder 에디터는 이를 "texture orientation" 조절기로 제공했습니다.
  • IsColored는 플래그 워드이며 비트 0만 의미가 있습니다. 정식판 프리미티브의 절반 이상이 여기에 다른 값을 저장합니다(비트 0이 꺼진 텍스처 면, 비트 0이 켜진 텍스처 없는 면). 비트 0이 켜진 면은 ColorIndex의 하위 바이트로 채웁니다.
  • **OffsetToTextureName == 0**은 텍스처가 없다는 뜻입니다. IsColored의 비트 0도 꺼져 있으면 그 프리미티브는 그려지지 않습니다 — Cavedog은 이를 충돌 전용 면이나 순수한 스크립트 기준점용 면에 씁니다.
  • 찾을 수 없는 텍스처 이름(대소문자 구분 없이 조회)이 있는 면은 단색 여부와 상관없이 **팔레트 인덱스 0xd1**로 채워집니다.
  • TA에서는 모서리가 네 개인 면에만 텍스처가 입혀집니다. 모서리 0이 텍스처의 왼쪽 위 텍셀에 오고, 이어서 시계 방향입니다. 단색이 아닌 텍스처 삼각형이나 n각형은 그려지지 않습니다(TA: Kingdoms 모델은 여기에도 텍스처를 입힙니다).
  • 면은 앞쪽에서만 그려집니다: 파일 좌표에서 오른손 법칙에 따라 모서리 순서가 향하는 쪽이 앞쪽입니다.
  • Cavedog이 만든 파일에서는 텍스처 이름 풀이 파일 오프셋 0x34에서 시작합니다(루트 오브젝트 헤더 바로 뒤). 이는 관례일 뿐 형식의 요구 사항이 아닙니다.

Important

게임이 프리미티브를 그리는 방식(kbot-io의 Primitive.Style):

if (texture_name != "" && !texture_found)  fill(0xd1);
else if (IsColored & 1)                   fill(ColorIndex & 0xff);
else if (texture_found && corners == 4)   texture();
else                                      /* not drawn */;

이를 잘못 처리하는 것이 가장 흔한 3DO 렌더링 버그입니다 — 충돌 전용 면이 있는 모델이 아무 팔레트 색으로나 채워져 버립니다. kbot 유닛 뷰어의 모델 JSON은 이 규칙을 서버 쪽에서 적용하며(각 프리미티브에 style, fillIndex, colorRGB가 담기고, 게임이 절대 그리지 않는 면은 빠짐), 지형지물 서랍의 오브젝트 미리보기도 이 규칙으로 렌더링합니다.


실제 예제 — armsy.3do(Arm Shipyard)

Dark Rain의 원래 해설은 Arm Shipyard를 사용했습니다. 루트 헤더를 읽으면 다음과 같습니다.

00000000 Object
  01 00 00 00      VersionSignature = 1
  C4 00 00 00      VertexCount      = 196 (0xC4)
  6D 00 00 00      PrimitiveCount   = 109 (0x6D)
  00 00 00 00      SelectionPrim    = 0 (= primitive #0)
  00 00 00 00 X    XFromParent  = 0
  00 00 00 00 Y    YFromParent  = 0
  00 00 00 00 Z    ZFromParent  = 0
  E5 1A 00 00      → "base"
  00 00 00 00      Always0
  15 04 00 00      → VertexArray
  45 0D 00 00      → PrimitiveArray
  00 00 00 00      SiblingOffset = 0 (root has no sibling)
  EA 1A 00 00      → child object (turret1)

자식/형제 체인을 따라가면 다음 피스 트리가 나옵니다.

base
├── turret1
│   └── nano1
│       └── beam1
├── turret2
│   └── nano2
│       └── beam2
├── slip
├── light
├── explode
├── explode1
└── explode2

slip 같은 일부 말단은 프리미티브가 0개인 정점 하나짜리 오브젝트입니다 — 이들은 COB 스크립트의 기준점으로만 존재합니다(emit-sfx from slip;, move slip to z-axis [-10] speed [20];).


주의할 점

Warning

위쪽은 Z가 아니라 Y입니다. Cavedog의 도구와 대부분의 서드파티 문서는 Y를 수직축으로 씁니다. Z가 위쪽인 엔진(Blender, 최신 Unreal)에 축을 바꾸지 않고 3DO를 가져오면 모든 유닛이 옆으로 누워 버립니다.

  • 정점은 전역이 아니라 오브젝트의 지역 좌표입니다. 두 오브젝트의 정점 배열이 똑같을 수 있는데, 이는 데이터를 공유한다는 뜻이 아니라 대칭 형상이라는 표시입니다.
  • OffsetToSelectionPrim은 인덱스이며, 그 프리미티브는 절대 그려지지 않습니다. 게임은 이를 슬롯 0으로 옮기고 슬롯 0을 건너뜁니다. 정식판 모델은 자식 피스에도 인덱스를 저장하며, -1은 없음을 뜻합니다. kbot의 모델 JSON은 숨겨진 프리미티브에 hidden 표시를 남기고 selectionPrim이 그것을 가리키게 합니다.
  • Always0은 관찰된 콘텐츠에서 정말로 항상 0입니다. 1998년부터 "반례를 찾으면 알려 달라"는 필드였지만 아무도 찾지 못했습니다.
  • 고정소수점 배율은 1 / 65536입니다 — 즉 각 int32 좌표의 하위 16비트가 소수부입니다. 0x00010000으로 쓰인 정점 좌표는 "1.0 월드 단위"입니다.
  • 텍스처 이름은 대소문자를 구분하지 않지만 대소문자를 보존합니다. 엔진은 소문자로 바꾼 문자열을 해시하고, 파일에는 에디터가 만든 대소문자 그대로 저장됩니다.
  • TA: Kingdoms는 발전된 변형을 씁니다. 프리미티브별 추가 데이터와 진짜 스키닝 모델이 있습니다. kbot은 현재 이를 파싱하지 않으며, iiCompleteDestruction 프로젝트(kbot의 CLAUDE.md 참고)에 작동하는 TA:K 메시 코드가 있습니다.

일반적인 크기

항목 Cavedog objects3d/*.3do에서 관찰된 범위
파일 크기 400 B – 60 KB
모델당 정점 수 8 – 1000(커맨더: 약 250)
모델당 프리미티브 수 6 – 800
모델당 피스 수 1 – 80(Krogoth)
box.3do(단순한 정육면체) 420 B
armcom.3do(Arm Commander) 약 25 KB
corkrog.3do(Krogoth) 약 60 KB
오브젝트별 헤더 항상 52바이트
프리미티브별 헤더 항상 32바이트

부록 — objects3d/box.3do 전체 헥스 분석

box.3do는 420바이트짜리 정육면체입니다 — 루트 오브젝트 하나, 정점 8개, 사각형 면 6개, 서로 다른 텍스처 4개, 자식 없음. 처음부터 끝까지 따라가기에 충분히 작으면서도 형식의 모든 필드를 다룹니다.

전체 헥스 덤프:

00000000: 0100 0000 0800 0000 0600 0000 0400 0000   ← Object header
00000010: 0000 0000 0000 0000 0000 0000 a001 0000      (52 bytes)
00000020: 0000 0000 8000 0000 e000 0000 0000 0000
00000030: 0000 0000 4172 6d56 3362 0041 726d 5633   ← Texture-name pool
00000040: 6300 4172 6d56 3361 0041 726d 5633 6400      "ArmV3b\0..."
00000050: 0100 0200 0000 0300 0200 0600 0400 0000   ← Vertex-index array
00000060: 0600 0200 0100 0500 0500 0100 0300 0700      (uint16 × 24)
00000070: 0000 0400 0700 0300 0600 0500 0700 0400
00000080: 0000 f0ff 0000 0000 0000 f0ff 0000 f0ff   ← Vertex array
00000090: 0080 1f00 0000 1000 0000 f0ff 0080 1f00      (8 × 12 bytes)
000000a0: 0000 f0ff 0000 f0ff 0000 0000 0000 1000
000000b0: 0000 1000 0000 0000 0000 f0ff 0000 1000
000000c0: 0080 1f00 0000 1000 0000 1000 0080 1f00
000000d0: 0000 f0ff 0000 1000 0000 0000 0000 1000
000000e0: 0000 0000 0400 0000 0000 0000 5000 0000   ← Primitive 0
000000f0: 3400 0000 0000 0000 0000 0000 0000 0000      (32 bytes)
00000100: 0000 0000 0400 0000 0000 0000 5800 0000   ← Primitive 1
00000110: 3b00 0000 0000 0000 0000 0000 0000 0000
00000120: 0000 0000 0400 0000 0000 0000 6000 0000   ← Primitive 2
00000130: 4200 0000 0000 0000 0000 0000 0000 0000
00000140: 0000 0000 0400 0000 0000 0000 6800 0000   ← Primitive 3
00000150: 3b00 0000 0000 0000 0000 0000 0000 0000
00000160: 0000 0000 0400 0000 0000 0000 7000 0000   ← Primitive 4
00000170: 3b00 0000 0000 0000 0000 0000 0000 0000
00000180: 0000 0000 0400 0000 0000 0000 7800 0000   ← Primitive 5
00000190: 4900 0000 0000 0000 0000 0000 0000 0000
000001a0: 424f 5800                                  ← "BOX\0"

1. 루트 오브젝트 헤더(0x00..0x34)

오프셋 바이트 필드 값
0x00 01 00 00 00 VersionSignature 1 ✓
0x04 08 00 00 00 NumberOfVertexes 8(정육면체 꼭짓점)
0x08 06 00 00 00 NumberOfPrimitives 6(정육면체 면)
0x0C 04 00 00 00 OffsetToSelectionPrim 4
0x10 00 00 00 00 XFromParent 0
0x14 00 00 00 00 YFromParent 0
0x18 00 00 00 00 ZFromParent 0
0x1C a0 01 00 00 OffsetToObjectName 0x1A0 → "BOX"
0x20 00 00 00 00 Always0 0
0x24 80 00 00 00 OffsetToVertexArray 0x80
0x28 e0 00 00 00 OffsetToPrimitiveArray 0xE0
0x2C 00 00 00 00 OffsetToSiblingObject 0(루트에는 형제가 없음)
0x30 00 00 00 00 OffsetToChildObject 0(말단 — 자식 없음)

2. 텍스처 이름 풀(0x34..0x50)

널 종료 문자열 네 개가 연달아 붙어 있습니다.

오프셋 문자열
0x34 "ArmV3b"
0x3B "ArmV3c"
0x42 "ArmV3a"
0x49 "ArmV3d"

이들은 anims/textures.gaf(또는 TA:K의 팔레트 시트)의 시퀀스를 참조합니다. Cavedog의 관례는 텍스처 풀을 0x34(루트 헤더 바로 뒤)에 두는 것이지만, 형식이 요구하는 것은 오프셋이 유효하다는 것뿐이므로 문자열은 어디에 있어도 됩니다.

3. 정점 인덱스 배열(0x50..0x80)

uint16[24] 블록 하나 — 인덱스 네 개씩 여섯 묶음으로, 사각형 면 하나에 한 묶음입니다. 2바이트씩 읽으면:

오프셋 인덱스 사용하는 프리미티브
0x50 1, 2, 0, 3 프리미티브 0
0x58 2, 6, 4, 0 프리미티브 1
0x60 6, 2, 1, 5 프리미티브 2
0x68 5, 1, 3, 7 프리미티브 3
0x70 0, 4, 7, 3 프리미티브 4
0x78 6, 5, 7, 4 프리미티브 5

각 행은 "정육면체 면 하나의 꼭짓점 네 개(반시계 방향 순서)"를 아래 정점 배열에 대한 인덱스로 나타낸 것입니다.

4. 정점 배열(0x80..0xE0)

정점 8개 × (int32 X, int32 Y, int32 Z) = 96바이트. 처음 세 개를 리틀 엔디언으로 디코딩하면:

# 바이트 부호 있는 int32로 본 (X, Y, Z)
0 00 00 f0 ff 00 00 00 00 00 00 f0 ff (-0x1000, 0, -0x1000)
1 00 00 f0 ff 00 80 1f 00 00 00 10 00 (-0x1000, +0x1F8000, +0x1000)
2 00 00 f0 ff 00 80 1f 00 00 00 f0 ff (-0x1000, +0x1F8000, -0x1000)
…

X와 Z의 ±0x1000은 꼭짓점을 고정소수점 단위 ±4096에 놓습니다. Y는 0(바닥)에서 +0x1F8000(정육면체 꼭대기)까지 걸칩니다. 1/65536 고정소수점 배율을 적용하면 X/Z로는 약 0.0625 월드 단위, Y로는 높이 약 31 월드 단위인 정육면체가 됩니다 — TA의 제작 도구는 "키 큰 소품"에 Y를 늘이는 관례를 씁니다.

5. 프리미티브 배열(0xE0..0x1A0)

프리미티브 6개 × 32바이트. 0xE0의 프리미티브 0을 읽으면:

오프셋 바이트 필드 값
0xE0 00 00 00 00 ColorIndex 0(무시됨 — 텍스처 면)
0xE4 04 00 00 00 NumberOfVertexIndexes 4(사각형)
0xE8 00 00 00 00 Always0 0
0xEC 50 00 00 00 OffsetToVertexIndexArray 0x50(위의 첫 번째 4-튜플)
0xF0 34 00 00 00 OffsetToTextureName 0x34 → "ArmV3b"
0xF4 00 00 00 00 Unknown1 0
0xF8 00 00 00 00 Unknown2 0
0xFC 00 00 00 00 IsColored 0(텍스처 면)

프리미티브 여섯 개를 모두 요약하면:

# 오프셋 VtxIdxArray TextureName 텍스처
0 0xE0 0x50 0x34 ArmV3b
1 0x100 0x58 0x3B ArmV3c
2 0x120 0x60 0x42 ArmV3a
3 0x140 0x68 0x3B ArmV3c
4 0x160 0x70 0x3B ArmV3c
5 0x180 0x78 0x49 ArmV3d

세 면이 ArmV3c 텍스처를 공유하고, 나머지 세 면은 각자 고유한 텍스처를 씁니다. 텍스처 재사용은 텍스처 풀 오프셋을 다시 쓰는 것일 뿐이며, 파일이 명시적으로 중복을 제거하지는 않습니다.

6. 오브젝트 이름(0x1A0..0x1A4)

424f 5800   →  "BOX\0"

전체 파일 크기: 0x1A4 = 420바이트. ✓

kbot으로 다시 읽기

# The web UI dissects 3DOs interactively
kbot mount $(kbot ctx path) --server
# → browse to objects3d/box.3do

Go에서(formats/objects3d):

import "github.com/coreprime/kbot/formats/objects3d"

model, _ := objects3d.LoadFromFile("objects3d/box.3do")
fmt.Println(model.Root.Name)                  // BOX
fmt.Println(len(model.Root.Vertices))         // 8
fmt.Println(len(model.Root.Primitives))       // 6
fmt.Println(model.Textures())                 // [ArmV3b ArmV3c ArmV3a ArmV3d]

레퍼런스 카탈로그의 실제 예

3DO 복잡도 순으로 정렬한 유닛들입니다(FBI의 Objectname 필드는 모델의 확장자를 뺀 파일 이름입니다 — reference-ta/ta-units.md 참고).

  • 아주 단순 — BOX(위의 해설, 420바이트, 정점 8개, 사각형 6개), 대부분의 지뢰(ARMMINE1–6, CORMINE1–6).
  • 피스 하나 — ARMRAD(레이더), ARMTIDE(조력 발전기) — 프리미티브 구조를 공부하기 좋은 오브젝트 하나짜리 메시.
  • 두 발로 걷는 kbot — ARMCK, ARMHAM, ARMPW — COB의 Walk 스크립트와 비교해 볼 수 있는 여러 피스로 된 계층.
  • 포탑이 있는 차량 — ARMBULL, ARMSAM, CORREAP — turret1 → barrel1 부모/자식 패턴을 보여 줍니다.
  • 가장 큰 모델 — CORKROG(Krogoth)와 ARMCOM(Commander) — 게임에서 가장 복잡한 .3do 파일.

함께 보기

  • COB — 이름으로 3DO 피스를 움직이는 스크립트 명령.
  • GAF — 3DO 프리미티브가 참조하는 텍스처 페이지(anims/textures.gaf).
  • PAL — 텍스처 없는 프리미티브를 칠하는 데 쓰는 팔레트.
  • FBI — ObjectName(= 확장자를 뺀 3DO 파일 이름)으로 3DO 모델을 참조하는 유닛 정의.
  • 용어집 — piece, anchor object, fixed-point.