3DO——层级式 3D 模型
.3do文件是一棵 3D 对象树——顶点、图元(primitive)和纹理引用按父子层级排列。游戏中的每个单位、每个带几何形状的静态地图物件,以及每个带 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>; 时,引擎恰好旋转这一个部件——挂在它下面的一切(前臂、手、枪口)都会自动跟随。这里没有现代意义上的蒙皮权重和骨骼;每个部件都是一个刚性的子网格,以相对父部件的固定偏移挂接。
磁盘布局
每个对象是一个 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;
定点数的约定是:整数部分 = 游戏世界单位;除以 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 指向一个 ASCII 字符串,它命名 anims/textures.gaf(或横扫千军:王国中的对应文件)中的一个序列。有几点需要了解:
- 没有 UV 坐标。 引擎根据图元的顶点顺序生成 UV。要“旋转”一张纹理,就重新排列图元
VertexIndexArray中的顶点索引。最初的 3do Builder 编辑器把这一点做成了一个“纹理方向”旋钮。 IsColored是一个标志字,只有第 0 位有意义。 超过一半的零售版图元在其中存放了其他值(带纹理的面第 0 位为 0,不带纹理的面第 0 位为 1)。第 0 位被置位的面用ColorIndex的低字节填充。OffsetToTextureName == 0表示没有纹理。如果IsColored的第 0 位也为 0,该图元不会被绘制——Cavedog 把这种面用于仅作碰撞或纯粹作为脚本锚点的面。- 无法解析的纹理名(不区分大小写查找)会让该面以调色板索引
0xd1填充,无论是否着色。 - 在 TA 中,只有四角面会贴纹理,角 0 对应纹理的左上纹素,然后按顺时针排列。不着色的带纹理三角形或多边形不会被绘制(横扫千军:王国的模型会给它们贴纹理)。
- 面只从正面绘制:按右手定则,在文件坐标系中由其角点顺序决定朝向的那一面。
- 在 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 渲染 bug——一个带有仅作碰撞之用的面的模型,最终会被填上随机的调色板颜色。kbot 单位查看器的模型 JSON 在服务器端应用这些规则(每个图元都带有 style、fillIndex 和 colorRGB,游戏从不绘制的面则被剔除),地图物件绘制器的对象预览也按这些规则渲染。
实例解析——armsy.3do(Arm 船坞)
Dark Rain 最初的详解使用的是 Arm 船坞(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,是只有一个顶点、零个图元的对象——它们存在的唯一目的,是充当 COB 脚本的锚点(emit-sfx from slip;、move slip to z-axis [-10] speed [20];)。
坑点
Warning
向上的是 Y,不是 Z。 Cavedog 的工具和大多数第三方文档都以 Y 作为竖直轴。如果你把 3DO 导入一个 Z 轴向上的引擎(Blender、现代的 Unreal)而不交换坐标轴,每个单位都会侧躺着。
- 顶点是对象局部的,不是全局的。两个对象可以有完全相同的顶点数组——这是镜像几何的迹象,而不是共享数据。
OffsetToSelectionPrim是一个索引,而且对应的图元永远不会被绘制。 游戏把它移到槽位 0,并跳过槽位 0。零售版模型在子部件上也存有索引;-1表示没有。kbot 的模型 JSON 保留这个隐藏的图元,将其标记为hidden,并让selectionPrim指向它。Always0在观察到的内容中确实总是 0。 从 1998 年起,它就是那个“如果你找到反例请告诉我们”的字段,而至今没有人找到。- 定点数比例是
1 / 65536——也就是说,每个int32坐标的低 16 位是小数部分。写作0x00010000的顶点表示“1.0 个世界单位”。 - 纹理名不区分大小写,但保留大小写。 引擎对转成小写的字符串做哈希;文件中存储的是编辑器生成的任何大小写形式。
- 横扫千军:王国使用一种演进的变体,带有额外的逐图元数据和真正的蒙皮模型。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 指挥官) |
约 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)
四个以 NUL 结尾的字符串,首尾相接紧密排列:
| 偏移量 | 字符串 |
|---|---|
0x34 |
"ArmV3b" |
0x3B |
"ArmV3c" |
0x42 |
"ArmV3a" |
0x49 |
"ArmV3d" |
它们引用 anims/textures.gaf(或 TA:K 的调色板纹理表)中的序列。Cavedog 的惯例是把纹理池放在 0x34——紧接在根对象头部之后——但格式只要求偏移量有效;这些字符串可以放在任何地方。
3. 顶点索引数组(0x50..0x80)
一个扁平的 uint16[24] 块——六组、每组四个索引,每个四边形面一组。每次读取两个字节:
| 偏移量 | 索引 | 使用它的图元 |
|---|---|---|
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)
八个顶点 × (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)
六个图元 × 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(上面的第一个四元组) |
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(指挥官)——游戏中最复杂的.3do文件。