Все страницы форматов ↓
Юниты, скрипты, конфигурации

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.md kbot) есть рабочий код для сеток 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.