Toutes les pages de formats ↓
Unités, scripts, configurations

3DO — modèles 3D hiérarchiques

Source : coreprime/kbot @ 0ab97bf · Modifier sur GitHub

Un fichier .3do est un arbre d'objets 3D : des sommets, des primitives et des références de textures organisés en hiérarchie parent/enfant. Chaque unité du jeu, chaque élément de décor statique de carte qui a une géométrie et chaque projectile doté d'un modèle 3D utilisent ce format. Un 3DO ne contient aucune donnée d'animation : les pièces sont déplacées à l'exécution par le script COB de l'unité.

Tip

Essayez vous-même.

# 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

Consultez la référence de kbot mount de la CLI.

Depuis Go. Utilisez 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))
}

Pourquoi le 3DO est construit ainsi

Le .3do était la réponse de Cavedog à la question « comment laisser les scripts animer une unité sans calculs matriciels coûteux ? » — et la réponse, c'est hiérarchie + transformations par pièce. L'unité est un arbre de pièces nommées :

armcom (root)
├── pelvis
│   ├── torso
│   │   ├── lupperarm
│   │   │   └── lforearm
│   │   │       └── lhand
│   │   ├── rupperarm  ...
│   │   └── head
│   ├── lthigh
│   │   └── lleg
│   └── rthigh
│       └── rleg
└── ...

Quand le script COB dit turn rupperarm to x-axis <30> speed <60>;, le moteur fait tourner exactement cette pièce — et tout ce qui y est rattaché en dessous (avant-bras, main, bouche du canon) suit automatiquement. Il n'y a ni poids de skinning ni os au sens moderne : chaque pièce est un sous-maillage rigide attaché à un décalage fixe par rapport à son parent.


Disposition sur le disque

Chaque objet est un en-tête de 52 octets suivi de charges utiles en tableaux nommés. L'en-tête contient les offsets vers ses enfants, ses frères, ses sommets et ses primitives.

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;

Un sommet est formé de trois composantes int32 en virgule fixe :

typedef struct { int32 X, Y, Z; } Vertex;

La convention de virgule fixe est la suivante : partie entière = unité du monde du jeu ; diviser par 65 536 pour la précision inférieure à l'unité. Les unités de Cavedog mesurent en général de quelques dizaines à quelques centaines d'unités du monde.

Une primitive est une face (ou un simple point, ou une ligne) :

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;

Le tableau d'index de sommets référencé par OffsetToVertexIndexArray est une liste plate d'index uint16 dans le tableau de sommets local de l'objet (et non dans un pool global).


Parcours de la hiérarchie

Chaque en-tête d'objet pointe vers le premier enfant et vers le frère suivant. C'est le codage d'arbre classique « premier enfant / frère suivant », pour lequel un parcours en profondeur s'écrit facilement :

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)

L'objet racine se trouve à l'offset 0 du fichier. Dans tous les modèles d'origine, son SiblingOffset vaut 0, mais le jeu suit aussi la chaîne des frères de la racine : chaque frère de la racine (et son sous-arbre) est chargé comme une pièce de premier niveau supplémentaire après le sous-arbre de la racine, et dessiné à ses propres coordonnées de sommets — la transformation de pièces du jeu part de la racine et ne leur applique aucun décalage de pièce. Le JSON de modèle de kbot les porte sous rootSiblings, et l'explorateur de ressources les compte et les liste. Chaque nom de pièce du script COB doit correspondre à un nom de pièce de cet arbre (sans tenir compte de la casse) ; en cas de discordance, l'animation du script ne fait silencieusement rien.


Texturage

OffsetToTextureName pointe vers une chaîne ASCII qui nomme une séquence de anims/textures.gaf (ou de son équivalent dans TA: Kingdoms). Quelques points à connaître :

  • Il n'y a pas de coordonnées UV. Le moteur génère les UV à partir de l'ordre des sommets de la primitive. Pour « faire pivoter » une texture, on réordonne les index de sommets dans le VertexIndexArray de la primitive. L'éditeur d'origine 3do Builder exposait cela sous la forme d'un réglage d'« orientation de texture ».
  • IsColored est un mot de flags dont seul le bit 0 compte. Plus de la moitié des primitives d'origine y stockent d'autres valeurs (des faces texturées avec le bit 0 à zéro, des faces non texturées avec le bit 0 à un). Une face dont le bit 0 est à un est remplie avec l'octet de poids faible de ColorIndex.
  • OffsetToTextureName == 0 signifie pas de texture. Si le bit 0 d'IsColored est lui aussi à zéro, la primitive n'est pas dessinée : Cavedog s'en sert pour des faces de collision seule ou de purs points d'ancrage de script.
  • Un nom de texture qui ne se résout pas (recherché sans tenir compte de la casse) remplit la face avec l'index de palette 0xd1, qu'elle soit colorée ou non.
  • Seules les faces à quatre coins sont texturées dans TA, le coin 0 sur le texel en haut à gauche de la texture, puis dans le sens horaire. Un triangle ou un n-gone texturé non coloré n'est pas dessiné (les modèles de TA: Kingdoms, eux, les texturent).
  • Les faces ne sont dessinées que de face : du côté vers lequel pointe l'ordre de leurs coins selon la règle de la main droite, en coordonnées du fichier.
  • Dans les fichiers produits par Cavedog, le pool des noms de textures commence à l'offset 0x34 (juste après l'en-tête de l'objet racine). C'est une convention, pas une exigence du format.

Important

Comment le jeu dessine une primitive (Primitive.Style de 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 */;

Se tromper sur ce point est le bogue de rendu 3DO le plus courant : un modèle avec des faces de collision seule finit rempli d'une couleur de palette aléatoire. Le JSON de modèle de la visionneuse d'unités de kbot applique ces règles côté serveur (chaque primitive porte style, fillIndex et colorRGB, et les faces que le jeu ne dessine jamais sont omises), et les aperçus d'objets du panneau des éléments de décor sont rendus avec elles.


Exemple commenté — armsy.3do (chantier naval Arm)

Le parcours d'origine de Dark Rain utilisait le chantier naval Arm. La lecture de l'en-tête racine donne :

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)

Suivre la chaîne enfant/frère produit l'arbre des pièces :

base
├── turret1
│   └── nano1
│       └── beam1
├── turret2
│   └── nano2
│       └── beam2
├── slip
├── light
├── explode
├── explode1
└── explode2

Certaines feuilles comme slip sont des objets à un seul sommet et sans primitive : ils n'existent que comme points d'ancrage pour les scripts COB (emit-sfx from slip;, move slip to z-axis [-10] speed [20];).


Pièges

Warning

L'axe vertical est Y, pas Z. Les outils de Cavedog et la plupart des documentations tierces utilisent Y pour l'axe vertical. Si vous importez un 3DO dans un moteur où Z pointe vers le haut (Blender, Unreal moderne) sans permuter les axes, toutes les unités se retrouvent couchées sur le côté.

  • Les sommets sont locaux à l'objet, et non globaux. Deux objets peuvent avoir des tableaux de sommets identiques : c'est le signe d'une géométrie en miroir, pas de données partagées.
  • OffsetToSelectionPrim est un index, et cette primitive n'est jamais dessinée. Le jeu la déplace dans l'emplacement 0 et ignore l'emplacement 0. Les modèles d'origine stockent aussi des index sur les pièces enfants ; -1 signifie aucune. Le JSON de modèle de kbot garde la primitive cachée, marquée hidden, et fait pointer selectionPrim vers elle.
  • Always0 vaut vraiment toujours 0 dans le contenu observé. C'est le champ « dites-nous si vous trouvez un contre-exemple » depuis 1998, et personne n'en a trouvé.
  • L'échelle de la virgule fixe est 1 / 65536 : les 16 bits de poids faible de chaque coordonnée int32 sont la partie fractionnaire. Un sommet écrit 0x00010000 vaut « 1,0 unité du monde ».
  • Les noms de textures sont insensibles à la casse, mais la casse est conservée. Le moteur hache les chaînes passées en minuscules ; le fichier stocke la casse produite par l'éditeur.
  • TA: Kingdoms utilise une variante évoluée, avec des données supplémentaires par primitive et un vrai modèle de skinning. kbot ne l'analyse pas pour l'instant ; le projet iiCompleteDestruction (voir le CLAUDE.md de kbot) contient du code fonctionnel pour les maillages de TA:K.

Tailles typiques

Ressource Plage observée dans les objects3d/*.3do de Cavedog
Taille de fichier 400 o – 60 Ko
Sommets par modèle 8 – 1 000 (commandant : ~250)
Primitives par modèle 6 – 800
Pièces par modèle 1 – 80 (Krogoth)
box.3do (cube trivial) 420 o
armcom.3do (commandant Arm) ~25 Ko
corkrog.3do (Krogoth) ~60 Ko
En-tête par objet toujours 52 octets
En-tête par primitive toujours 32 octets

Annexe — dissection hexadécimale complète de objects3d/box.3do

box.3do est un cube de 420 octets : un objet racine, 8 sommets, 6 faces quadrilatères, 4 textures différentes, aucun enfant. Il est assez petit pour être parcouru de bout en bout et exerce tous les champs du format.

Vidage hexadécimal complet :

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. En-tête de l'objet racine (0x00..0x34)

Offset Octets Champ Valeur
0x00 01 00 00 00 VersionSignature 1 ✓
0x04 08 00 00 00 NumberOfVertexes 8 (coins du cube)
0x08 06 00 00 00 NumberOfPrimitives 6 (faces du cube)
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 (la racine n'a pas de frère)
0x30 00 00 00 00 OffsetToChildObject 0 (feuille — pas d'enfants)

2. Pool des noms de textures (0x34..0x50)

Quatre chaînes terminées par NUL, mises bout à bout :

Offset Chaîne
0x34 "ArmV3b"
0x3B "ArmV3c"
0x42 "ArmV3a"
0x49 "ArmV3d"

Elles font référence à des séquences de anims/textures.gaf (ou d'une planche à palette de TA:K). La convention de Cavedog place le pool des textures en 0x34, juste après l'en-tête racine, mais le format exige seulement que les offsets soient valides : les chaînes peuvent se trouver n'importe où.

3. Tableau d'index de sommets (0x50..0x80)

Un bloc plat uint16[24] : six suites de quatre index, une suite par face quadrilatère. En lisant deux octets à la fois :

Offset Index Utilisé par la primitive
0x50 1, 2, 0, 3 Primitive 0
0x58 2, 6, 4, 0 Primitive 1
0x60 6, 2, 1, 5 Primitive 2
0x68 5, 1, 3, 7 Primitive 3
0x70 0, 4, 7, 3 Primitive 4
0x78 6, 5, 7, 4 Primitive 5

Chaque ligne correspond aux « quatre coins (dans le sens antihoraire) d'une face du cube », exprimés comme des index dans le tableau de sommets ci-dessous.

4. Tableau de sommets (0x80..0xE0)

Huit sommets × (int32 X, int32 Y, int32 Z) = 96 octets. Lecture des trois premiers en décodage little-endian :

# Octets (X, Y, Z) en int32 signés
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 sur X et Z placent les coins à ±4 096 unités de virgule fixe. Y va de 0 (base) à +0x1F8000 (haut du cube). Appliquez l'échelle de virgule fixe 1/65536 et vous obtenez un cube d'environ 0,0625 unité du monde en X/Z, avec Y qui monte à environ 31 unités du monde de hauteur — les outils de création de TA utilisent une convention de Y étiré pour les « décors hauts ».

5. Tableau de primitives (0xE0..0x1A0)

Six primitives × 32 octets. Lecture de la primitive 0 en 0xE0 :

Offset Octets Champ Valeur
0xE0 00 00 00 00 ColorIndex 0 (ignoré — texturée)
0xE4 04 00 00 00 NumberOfVertexIndexes 4 (quadrilatère)
0xE8 00 00 00 00 Always0 0
0xEC 50 00 00 00 OffsetToVertexIndexArray 0x50 (le premier quadruplet ci-dessus)
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 (texturée)

Récapitulatif des six primitives :

# Offset VtxIdxArray TextureName Texture
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

Trois faces partagent la texture ArmV3c, les trois autres ont chacune la leur. La réutilisation d'une texture n'est qu'une réutilisation d'offset dans le pool des textures : le fichier ne fait pas de déduplication explicite.

6. Nom de l'objet (0x1A0..0x1A4)

424f 5800   →  "BOX\0"

Taille totale du fichier : 0x1A4 = 420 octets. ✓

Le relire avec kbot

# The web UI dissects 3DOs interactively
kbot mount $(kbot ctx path) --server
# → browse to objects3d/box.3do

Depuis 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]

Exemples concrets dans le catalogue de référence

Des unités classées par complexité de 3DO (le champ Objectname du FBI est le nom de fichier du modèle sans extension — voir reference-ta/ta-units.md) :

  • Triviales — BOX (le parcours ci-dessus ; 420 octets, 8 sommets, 6 quadrilatères), la plupart des mines (ARMMINE1–6, CORMINE1–6).
  • À une seule pièce — ARMRAD (radar), ARMTIDE (générateur marémoteur) : des maillages à un seul objet, bien adaptés à l'étude de la disposition des primitives.
  • Kbots bipèdes — ARMCK, ARMHAM, ARMPW : des hiérarchies à plusieurs pièces à comparer avec le script Walk de leurs COB.
  • Véhicules à tourelle — ARMBULL, ARMSAM, CORREAP : ils montrent le schéma parent/enfant turret1 → barrel1.
  • Plus gros modèles — CORKROG (Krogoth) et ARMCOM (commandant) : les fichiers .3do les plus complexes du jeu.

Voir aussi

  • COB — les commandes de script qui animent les pièces 3DO par leur nom.
  • GAF — les pages de textures (anims/textures.gaf) référencées par les primitives 3DO.
  • PAL — la palette qui sert à colorer les primitives non texturées.
  • FBI — les définitions d'unités qui font référence aux modèles 3DO par ObjectName (= nom de fichier du 3DO sans extension).
  • Glossaire — pièce, objet d'ancrage, virgule fixe.