3DO — иерархические 3D-модели
Источник: coreprime/kbot @ 0ab97bf · Редактировать на GitHub
Файл
.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
См. справочник по 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-байтовый заголовок, за которым идут полезные данные (payload) в виде именованных массивов. Заголовок содержит смещения к потомкам, соседним объектам, вершинам и примитивам.
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, но игра проходит и по
цепочке соседей корня: каждый сосед корня (вместе со своим поддеревом)
загружается как ещё одна деталь верхнего уровня после поддерева корня и
рисуется в собственных координатах вершин — преобразование деталей в
игре начинается с корня и не применяет к ним никаких смещений деталей. В
JSON модели kbot они передаются как rootSiblings, а обозреватель
ресурсов подсчитывает их и выводит списком.
Каждое имя детали в COB-скрипте должно совпадать с именем детали в этом
дереве (без учёта регистра); при несовпадении анимация скрипта молча
ничего не делает.
Текстурирование
OffsetToTextureName указывает на ASCII-строку с именем
последовательности в anims/textures.gaf (или его аналоге в
TA: Kingdoms). Что нужно знать:
- UV-координат нет. Движок вычисляет UV по порядку вершин
примитива. Чтобы «повернуть» текстуру, переставьте индексы вершин в
VertexIndexArrayпримитива. В оригинальном редакторе 3do Builder это была настройка «texture orientation». IsColored— слово флагов, и значение имеет только бит 0. Больше половины оригинальных примитивов хранят в нём другие значения (текстурированные грани со сброшенным битом 0, нетекстурированные — с установленным). Грань с установленным битом 0 заливается младшим байтомColorIndex.OffsetToTextureName == 0означает отсутствие текстуры. Если бит 0 вIsColoredтоже сброшен, примитив не рисуется — так Cavedog делает грани только для столкновений или чисто опорные грани для скриптов.- Имя текстуры, которое не удаётся найти (поиск без учёта регистра),
заливает грань индексом палитры
0xd1независимо от цвета. - В TA текстурируются только четырёхугольные грани: угол 0 приходится на верхний левый тексель текстуры, дальше — по часовой стрелке. Нераскрашенный текстурированный треугольник или многоугольник не рисуется (модели TA: Kingdoms их текстурируют).
- Грани рисуются только с лицевой стороны — той, в которую они обращены по правилу правой руки при их порядке углов, в координатах файла.
- В файлах, созданных Cavedog, пул имён текстур начинается со смещения
0x34(сразу после заголовка корневого объекта). Это соглашение, а не требование формата.
Important
Как игра рисует примитив (Primitive.Style в kbot-io):
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: модель с гранями
только для столкновений оказывается залита случайным цветом палитры.
JSON модели в просмотрщике юнитов kbot применяет эти правила на стороне
сервера (у каждого примитива есть 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, — это объекты из одной вершины без
примитивов: они существуют только как опорные точки для
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означает отсутствие. JSON модели kbot сохраняет скрытый примитив с флагомhiddenи указывает на него вselectionPrim.Always0действительно всегда 0 во всех изученных файлах. С 1998 года это поле из разряда «сообщите, если найдёте контрпример», и никто так и не нашёл.- Масштаб фиксированной точки —
1 / 65536, то есть младшие 16 бит каждой координатыint32— дробная часть. Вершина, записанная как0x00010000, — это «1,0 единицы мира». - Имена текстур не зависят от регистра, но сохраняют его. Движок хэширует строки в нижнем регистре, а файл хранит тот регистр, который выдал редактор.
- TA: Kingdoms использует доработанный вариант с дополнительными
данными для каждого примитива и настоящей моделью скиннинга. kbot пока
его не разбирает; в проекте iiCompleteDestruction (см.
CLAUDE.mdkbot) есть рабочий код для сеток TA:K.
Типичные размеры
| Ресурс | Диапазон в objects3d/*.3do от Cavedog |
|---|---|
| Размер файла | 400 Б – 60 КБ |
| Вершин в модели | 8 – 1000 (Командир: ~250) |
| Примитивов в модели | 6 – 800 |
| Деталей в модели | 1 – 80 (Крогот) |
box.3do (простейший куб) |
420 Б |
armcom.3do (Командир ARM) |
~25 КБ |
corkrog.3do (Крогот) |
~60 КБ |
| Заголовок объекта | всегда 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 байт. Первые три,
декодированные как little-endian:
| # | Байты | (X, Y, Z) как знаковый int32 |
|---|---|---|
| 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) |
| … |
±0x1000 по X и Z помещают углы в ±4096 единиц фиксированной точки. Y
меняется от 0 (основание) до +0x1F8000 (верх куба). Применив масштаб
фиксированной точки 1/65536, получим куб размером примерно 0,0625
единицы мира по X/Z, а по Y — высотой около 31 единицы мира: инструменты
создания контента TA используют растянутую по Y систему для «высоких
объектов».
5. Массив примитивов (0xE0..0x1A0)
Шесть примитивов × 32 байта. Читаем примитив 0 по смещению 0xE0:
| Смещение | Байты | Поле | Значение |
|---|---|---|---|
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 (поле Objectname в FBI — это имя файла модели
без расширения, см.
reference-ta/ta-units.md):
- Простейшие —
BOX(разобран выше: 420 байт, 8 вершин, 6 четырёхугольников), большинство мин (ARMMINE1–6,CORMINE1–6). - Из одной детали —
ARMRAD(радар),ARMTIDE(приливный генератор): сетки из одного объекта, удобные для изучения устройства примитивов. - Двуногие К-боты —
ARMCK,ARMHAM,ARMPW: иерархии из нескольких деталей, которые можно сравнить со скриптомWalkв их COB. - Техника с башнями —
ARMBULL,ARMSAM,CORREAP: показывают схему родителя и потомкаturret1 → barrel1. - Самые большие модели —
CORKROG(Крогот) иARMCOM(Командир): самые сложные файлы.3doв игре.
См. также
- COB — команды скриптов, которые анимируют детали 3DO по имени.
- GAF — страницы текстур (
anims/textures.gaf), на которые ссылаются примитивы 3DO. - PAL — палитра, которой раскрашиваются нетекстурированные примитивы.
- FBI — описания юнитов, ссылающиеся на модели 3DO через
ObjectName(= имя файла 3DO без расширения). - Глоссарий — piece, anchor object, fixed-point.