3DO — ієрархічні 3D-моделі
Джерело: coreprime/kbot @ 0ab97bf · Редагувати на GitHub
Файл
.3do— це дерево 3D-об'єктів: вершини, примітиви й посилання на текстури, упорядковані в ієрархію «батько/нащадок». Цей формат використовують кожен юніт у грі, кожен статичний об'єкт мапи з геометрією та кожен снаряд із 3D-моделлю. У 3DO немає даних анімації; частини (pieces) рухає під час гри скрипт 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-байтовий заголовок, за яким ідуть дані в іменованих масивах. Заголовок містить зміщення до дочірніх і сусідніх (sibling) об'єктів, вершин і примітивів.
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 показував це як регулятор «орієнтації текстури». IsColored— це слово прапорців, і значення має лише біт 0. Понад половина релізних примітивів зберігає в ньому інші значення (текстуровані грані зі скинутим бітом 0, нетекстуровані — з установленим). Грань із установленим бітом 0 заливається молодшим байтомColorIndex.OffsetToTextureName == 0означає, що текстури немає. Якщо біт 0 уIsColoredтеж скинутий, примітив не малюється — Cavedog використовує це для граней, потрібних лише для зіткнень, або для чистих опорних граней скриптів.- Ім'я текстури, яке не вдається знайти (пошук ведеться без
урахування регістру), заливає грань індексом палітри
0xd1— незалежно від того, кольорова вона чи ні. - У TA текстуруються лише грані з чотирма кутами: кут 0 — у лівому верхньому текселі текстури, далі за годинниковою стрілкою. Некольоровий текстурований трикутник чи n-кутник не малюється (моделі 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, а
грані, яких гра ніколи не малює, пропущено), і за ними ж рендеряться
прев'ю об'єктів у панелі об'єктів (feature drawer).
Розібраний приклад — armsy.3do (Arm Shipyard)
Оригінальний розбір Dark Rain використовував 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означає відсутність. 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 (Krogoth) |
box.3do (тривіальний куб) |
420 Б |
armcom.3do (Arm Commander) |
~25 КБ |
corkrog.3do (Krogoth) |
~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(Krogoth) іARMCOM(Commander) — найскладніші файли.3doу грі.
Див. також
- COB — команди скриптів, що анімують частини 3DO за іменем.
- GAF — сторінки текстур (
anims/textures.gaf), на які посилаються примітиви 3DO. - PAL — палітра, якою розфарбовуються нетекстуровані примітиви.
- FBI — визначення юнітів, що посилаються на моделі 3DO через
ObjectName(= ім'я файлу 3DO без розширення). - Глосарій — piece, anchor object, fixed-point.