所有格式页面 ↓
单位、脚本与配置

3DO——层级式 3D 模型

来自 coreprime/kbot @ 0ab97bf · 在 GitHub 上编辑

.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 文件。

另见

  • COB——按名称驱动 3DO 部件动画的脚本命令。
  • GAF——被 3DO 图元引用的纹理页(anims/textures.gaf)。
  • PAL——为不带纹理的图元着色所用的调色板。
  • FBI——通过 ObjectName(= 不带扩展名的 3DO 文件名)引用 3DO 模型的单位定义。
  • 术语表——piece、anchor object、fixed-point。