3DO — 階層型3Dモデル
.3doファイルは3Dオブジェクトのツリーです。頂点、プリミティブ、テクスチャへの参照が親子の階層に並んでいます。ゲーム内のすべてのユニット、形状を持つすべての静的なマップ地形物、3Dモデルを持つすべての発射体がこの形式を使います。3DOにはアニメーションデータがありません。ピースは実行時に、ユニットの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>;と命じると、エンジンはまさにそのピースを回転させ、その下に付いているもの(前腕、手、銃口)はすべて自動的についていきます。現代的な意味でのスキンウェイトやボーンはありません。各ピースは、親から一定のオフセットの位置に取り付けられた剛体のサブメッシュです。
ディスク上の配置
各オブジェクトは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;
頂点は3つのint32の固定小数点成分からなります。
typedef struct { int32 X, Y, Z; } Vertex;
固定小数点の約束事は、整数部がゲームのワールド単位で、それより細かい精度は65536で割って得るというものです。Cavedogのユニットは、差し渡しがふつう数十から数百ワールド単位です。
プリミティブは面です(または1つの点か、線)。
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はASCII文字列を指し、その文字列はanims/textures.gaf(またはTA: Kingdomsでそれに相当するもの)のシーケンスを名指しします。知っておくべき点がいくつかあります。
- UV座標はありません。エンジンはプリミティブの頂点の順序からUVを生成します。テクスチャを「回転」させるには、プリミティブの
VertexIndexArrayにある頂点インデックスを並べ替えます。オリジナルの3do Builderエディターは、これを「テクスチャの向き」のつまみとして見せていました。 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の造船所)
Dark Rainによるオリジナルの解説では、ARMの造船所が使われていました。ルートのヘッダーを読むと次のようになります。
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のような一部の葉は、頂点が1つでプリミティブが0個のオブジェクトです。これらはCOBスクリプトのためのアンカーポイントとしてだけ存在します(emit-sfx from slip;、move slip to z-axis [-10] speed [20];)。
注意点
Warning
上はZではなくYです。Cavedogのツールとサードパーティの文書の多くは、Yを垂直軸に使います。軸を入れ替えずに3DOをZが上のエンジン(Blender、最近のUnreal)に読み込むと、すべてのユニットが横倒しになります。
- 頂点はオブジェクトごとのローカル座標です。グローバルではありません。2つのオブジェクトが同一の頂点配列を持つことがありますが、それは鏡像の形状のしるしであって、データを共有しているわけではありません。
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(クロゴス) |
box.3do(単純な立方体) |
420 B |
armcom.3do(ARMコマンダー) |
約25 KB |
corkrog.3do(クロゴス) |
約60 KB |
| オブジェクトごとのヘッダー | 常に52バイト |
| プリミティブごとのヘッダー | 常に32バイト |
付録 — objects3d/box.3doを16進で完全に分解する
box.3doは420バイトの立方体です。ルートオブジェクトが1つ、頂点が8個、四角形の面が6枚、テクスチャが4種類で、子はありません。最初から最後まで追えるほど小さく、それでいて形式のすべてのフィールドを使っています。
16進ダンプの全体です。
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)
NUL終端の文字列4つが、隙間なく詰めて並んでいます。
| オフセット | 文字列 |
|---|---|
0x34 |
"ArmV3b" |
0x3B |
"ArmV3c" |
0x42 |
"ArmV3a" |
0x49 |
"ArmV3d" |
これらはanims/textures.gaf(またはTA:Kのパレットシート)のシーケンスを参照しています。Cavedogの慣習ではテクスチャのプールをルートのヘッダー直後の0x34に置きますが、形式が求めるのはオフセットが有効であることだけで、文字列はどこに置いてもかまいません。
3. 頂点インデックス配列(0x50..0x80)
平坦なuint16[24]のブロックで、4つのインデックスの並びが6つ、四角形の面1枚につき1つずつあります。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 |
各行は「立方体の面1枚の4つの角(反時計回りの順)」を、下の頂点配列へのインデックスで表したものです。
4. 頂点配列(0x80..0xE0)
8個の頂点×(int32 X, int32 Y, int32 Z)で96バイトです。最初の3つをリトルエンディアンで読むと次のようになります。
| # | バイト列 | 符号付き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(テクスチャ付き) |
6個のプリミティブすべてをまとめると次のとおりです。
| # | オフセット | 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 |
3枚の面がArmV3cテクスチャを共有し、残りの3枚はそれぞれ固有のテクスチャを使います。テクスチャの再利用は、単にテクスチャプール内のオフセットの再利用です。ファイルが明示的に重複を除いているわけではありません。
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)。 - ピースが1つのもの —
ARMRAD(レーダー)、ARMTIDE(潮力発電機)。プリミティブの配置を調べるのに向いた、オブジェクト1つだけのメッシュです。 - 二足歩行のKボット —
ARMCK、ARMHAM、ARMPW。それぞれのCOBにあるWalkスクリプトと見比べられる、複数のピースからなる階層です。 - 砲塔付きの車両 —
ARMBULL、ARMSAM、CORREAP。turret1 → barrel1という親子のパターンがわかります。 - 最大のモデル —
CORKROG(クロゴス)とARMCOM(コマンダー)。ゲーム中で最も複雑な.3doファイルです。