Усі сторінки форматів ↓
Юніти, скрипти, конфігурації

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.