3DO — 계층형 3D 모델
.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파일.