3DO — modèles 3D hiérarchiques
Source : coreprime/kbot @ 0ab97bf · Modifier sur GitHub
Un fichier
.3doest 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
VertexIndexArrayde la primitive. L'éditeur d'origine 3do Builder exposait cela sous la forme d'un réglage d'« orientation de texture ». IsColoredest 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 deColorIndex.OffsetToTextureName == 0signifie pas de texture. Si le bit 0 d'IsColoredest 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.
OffsetToSelectionPrimest 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 ;-1signifie aucune. Le JSON de modèle de kbot garde la primitive cachée, marquéehidden, et fait pointerselectionPrimvers elle.Always0vaut 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éeint32sont la partie fractionnaire. Un sommet écrit0x00010000vaut « 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.mdde 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 scriptWalkde leurs COB. - Véhicules à tourelle —
ARMBULL,ARMSAM,CORREAP: ils montrent le schéma parent/enfantturret1 → barrel1. - Plus gros modèles —
CORKROG(Krogoth) etARMCOM(commandant) : les fichiers.3doles 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.