Tutte le pagine dei formati ↓
Unità, script, configurazioni

3DO — Modelli 3D gerarchici

Fonte: coreprime/kbot @ 0ab97bf · Modifica su GitHub

Un file .3do è un albero di oggetti 3D: vertici, primitive e riferimenti alle texture organizzati in una gerarchia padre/figlio. Ogni unità del gioco, ogni elemento statico della mappa dotato di geometria e ogni proiettile con un modello 3D usa questo formato. In un 3DO non ci sono dati di animazione: i pezzi (piece) vengono mossi durante il gioco dallo script COB dell'unità.

Tip

Provalo tu stesso.

# 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

Consulta il riferimento CLI di kbot mount.

Da Go. Usa 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))
}

Perché il 3DO è fatto così

Il .3do era la risposta di Cavedog alla domanda "come facciamo ad animare un'unità con gli script senza costosi calcoli matriciali?": la risposta è gerarchia + trasformazioni per singolo pezzo. L'unità è un albero di pezzi con nome:

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

Quando lo script COB dice turn rupperarm to x-axis <30> speed <60>;, il motore ruota esattamente quel pezzo, e tutto ciò che vi è attaccato sotto (avambraccio, mano, bocca dell'arma) lo segue automaticamente. Non ci sono pesi di skinning né ossa nel senso moderno; ogni pezzo è una sotto-mesh rigida attaccata a un offset fisso dal proprio genitore.


Struttura su disco

Ogni oggetto è un'intestazione di 52 byte seguita da payload organizzati in array con nome. L'intestazione contiene gli offset dei figli, dei fratelli, dei vertici e delle primitive.

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 vertice è composto da tre componenti int32 in virgola fissa:

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

La convenzione della virgola fissa è: numero intero = unità del mondo di gioco; dividi per 65536 per la precisione inferiore all'unità. Le unità di Cavedog misurano di solito da decine a centinaia di unità del mondo.

Una primitiva è una faccia (oppure un singolo punto, o una linea):

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;

L'array degli indici dei vertici a cui punta OffsetToVertexIndexArray è una lista piatta di indici uint16 nell'array dei vertici locale dell'oggetto (non in un pool globale).


Attraversamento della gerarchia

L'intestazione di ogni oggetto punta al primo figlio e al fratello successivo. È la classica codifica degli alberi "primo figlio / fratello successivo", per cui è facile scrivere una visita in profondità:

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'oggetto radice si trova all'offset 0 del file. In ogni modello originale il suo SiblingOffset vale 0, ma il gioco segue anche la catena dei fratelli della radice: ogni fratello della radice (con il suo sottoalbero) viene caricato come ulteriore pezzo di primo livello dopo il sottoalbero della radice, e disegnato alle proprie coordinate dei vertici, perché la trasformazione dei pezzi del gioco parte dalla radice e non applica loro alcun offset dei pezzi. Il JSON del modello di kbot li riporta come rootSiblings, e l'esploratore di asset li conta e li elenca. Ogni nome di pezzo nello script COB deve corrispondere a un nome di pezzo in questo albero (senza distinzione tra maiuscole e minuscole); una mancata corrispondenza fa sì che l'animazione dello script diventi silenziosamente un no-op.


Applicazione delle texture

OffsetToTextureName punta a una stringa ASCII che nomina una sequenza in anims/textures.gaf (o nel suo equivalente di TA: Kingdoms). Alcune cose da sapere:

  • Non ci sono coordinate UV. Il motore genera le UV dall'ordine dei vertici della primitiva. Per "ruotare" una texture si riordinano gli indici dei vertici nel VertexIndexArray della primitiva. L'editor originale 3do Builder lo presentava come una manopola di "orientamento della texture".
  • IsColored è una parola di flag e conta solo il bit 0. Più della metà delle primitive originali vi memorizza altri valori (facce con texture con il bit 0 a zero, facce senza texture con il bit 0 impostato). Una faccia con il bit 0 impostato viene riempita con il byte basso di ColorIndex.
  • OffsetToTextureName == 0 significa nessuna texture. Se anche il bit 0 di IsColored è a zero, la primitiva non viene disegnata: Cavedog lo usa per facce che servono solo per le collisioni o solo come ancoraggio per gli script.
  • Un nome di texture che non si risolve (cercato senza distinzione tra maiuscole e minuscole) riempie la faccia con l'indice della palette 0xd1, colorata o no.
  • In TA ricevono una texture solo le facce a quattro angoli, con l'angolo 0 sul texel in alto a sinistra della texture e poi in senso orario. Un triangolo o un n-gono con texture e non colorato non viene disegnato (i modelli di TA: Kingdoms invece applicano loro la texture).
  • Le facce vengono disegnate solo dal davanti: il lato verso cui è rivolto il loro ordine degli angoli secondo la regola della mano destra, nelle coordinate del file.
  • Nei file costruiti da Cavedog il pool dei nomi delle texture inizia all'offset 0x34 del file (subito dopo l'intestazione dell'oggetto radice). È una convenzione, non un requisito del formato.

Important

Come il gioco disegna una primitiva (Primitive.Style di 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 */;

Sbagliare questo punto è il bug più comune nel rendering dei 3DO: un modello con facce riservate alle collisioni finisce riempito di colori casuali della palette. Il JSON del modello del visualizzatore di unità di kbot applica queste regole lato server (ogni primitiva porta style, fillIndex e colorRGB, e le facce che il gioco non disegna mai vengono escluse), e le anteprime degli oggetti nel pannello degli elementi le usano per il rendering.


Esempio svolto — armsy.3do (Arm Shipyard)

L'analisi originale di Dark Rain usava l'Arm Shipyard. Leggendo l'intestazione della radice si ottiene:

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)

Seguendo la catena di figli e fratelli si ottiene l'albero dei pezzi:

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

Alcune foglie come slip sono oggetti con un solo vertice e nessuna primitiva: esistono solo come punti di ancoraggio per gli script COB (emit-sfx from slip;, move slip to z-axis [-10] speed [20];).


Insidie

Warning

L'asse verticale è Y, non Z. Gli strumenti di Cavedog e la maggior parte della documentazione di terze parti usano Y per l'asse verticale. Se importi un 3DO in un motore con Z verso l'alto (Blender, Unreal moderno) senza scambiare gli assi, ogni unità resta sdraiata su un fianco.

  • I vertici sono locali all'oggetto, non globali. Due oggetti possono avere array di vertici identici: è segno di geometria specchiata, non di dati condivisi.
  • OffsetToSelectionPrim è un indice, e quella primitiva non viene mai disegnata. Il gioco la sposta nello slot 0 e salta lo slot 0. I modelli originali memorizzano indici anche nei pezzi figli; -1 significa nessuna. Il JSON del modello di kbot mantiene la primitiva nascosta, contrassegnata come hidden, e fa puntare selectionPrim a essa.
  • Always0 vale davvero sempre 0 nei contenuti osservati. Dal 1998 è il campo "fateci sapere se trovate un controesempio", e nessuno l'ha trovato.
  • La scala della virgola fissa è 1 / 65536, cioè i 16 bit bassi di ogni coordinata int32 sono la parte frazionaria. Un vertice scritto 0x00010000 corrisponde a "1,0 unità del mondo".
  • I nomi delle texture non distinguono tra maiuscole e minuscole, ma le conservano. Il motore calcola l'hash delle stringhe in minuscolo; il file memorizza le maiuscole e minuscole prodotte dall'editor.
  • TA: Kingdoms usa una variante evoluta, con dati aggiuntivi per primitiva e un vero modello di skinning. Al momento kbot non la analizza; il progetto iiCompleteDestruction (vedi il CLAUDE.md di kbot) ha codice funzionante per le mesh di TA:K.

Dimensioni tipiche

Elemento Intervallo osservato negli objects3d/*.3do di Cavedog
Dimensione del file 400 B – 60 KB
Vertici per modello 8 – 1000 (comandante: ~250)
Primitive per modello 6 – 800
Pezzi per modello 1 – 80 (Krogoth)
box.3do (cubo banale) 420 B
armcom.3do (Arm Commander) ~25 KB
corkrog.3do (Krogoth) ~60 KB
Intestazione per oggetto sempre 52 byte
Intestazione per primitiva sempre 32 byte

Appendice — Dissezione esadecimale completa di objects3d/box.3do

box.3do è un cubo di 420 byte: un oggetto radice, 8 vertici, 6 facce quadrangolari, 4 texture diverse, nessun figlio. È abbastanza piccolo da percorrerlo da cima a fondo e mette alla prova ogni campo del formato.

Dump esadecimale completo:

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. Intestazione dell'oggetto radice (0x00..0x34)

Offset Byte Campo Valore
0x00 01 00 00 00 VersionSignature 1 ✓
0x04 08 00 00 00 NumberOfVertexes 8 (angoli del cubo)
0x08 06 00 00 00 NumberOfPrimitives 6 (facce del cubo)
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 radice non ha fratelli)
0x30 00 00 00 00 OffsetToChildObject 0 (foglia: nessun figlio)

2. Pool dei nomi delle texture (0x34..0x50)

Quattro stringhe terminate da NUL, impacchettate una dopo l'altra:

Offset Stringa
0x34 "ArmV3b"
0x3B "ArmV3c"
0x42 "ArmV3a"
0x49 "ArmV3d"

Queste fanno riferimento a sequenze in anims/textures.gaf (o in un foglio di palette di TA:K). La convenzione di Cavedog mette il pool delle texture a 0x34, subito dopo l'intestazione della radice, ma il formato richiede soltanto che gli offset siano validi; le stringhe possono stare ovunque.

3. Array degli indici dei vertici (0x50..0x80)

Un blocco piatto uint16[24]: sei gruppi di quattro indici, uno per ogni faccia quadrangolare. Leggendo due byte alla volta:

Offset Indici Usati dalla primitiva
0x50 1, 2, 0, 3 Primitiva 0
0x58 2, 6, 4, 0 Primitiva 1
0x60 6, 2, 1, 5 Primitiva 2
0x68 5, 1, 3, 7 Primitiva 3
0x70 0, 4, 7, 3 Primitiva 4
0x78 6, 5, 7, 4 Primitiva 5

Ogni riga rappresenta "i quattro angoli (in ordine antiorario) di una faccia del cubo", espressi come indici nell'array dei vertici qui sotto.

4. Array dei vertici (0x80..0xE0)

Otto vertici × (int32 X, int32 Y, int32 Z) = 96 byte. Leggendo i primi tre con una decodifica little-endian:

# Byte (X, Y, Z) come int32 con segno
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 su X e Z collocano gli angoli a ±4096 unità in virgola fissa. Y va da 0 (base) a +0x1F8000 (cima del cubo). Applicando la scala in virgola fissa 1/65536 si ottiene un cubo di circa 0,0625 unità del mondo in X/Z, con Y che raggiunge circa 31 unità del mondo di altezza: gli strumenti di creazione di TA usano una convenzione con Y allungata per gli "oggetti alti".

5. Array delle primitive (0xE0..0x1A0)

Sei primitive × 32 byte. Leggendo la primitiva 0 a 0xE0:

Offset Byte Campo Valore
0xE0 00 00 00 00 ColorIndex 0 (ignorato: con texture)
0xE4 04 00 00 00 NumberOfVertexIndexes 4 (quadrilatero)
0xE8 00 00 00 00 Always0 0
0xEC 50 00 00 00 OffsetToVertexIndexArray 0x50 (la prima quaterna qui sopra)
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 (con texture)

Riepilogo di tutte e sei le primitive:

# 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

Tre facce condividono la texture ArmV3c, le altre tre ne hanno una propria. Riutilizzare una texture significa semplicemente riutilizzare un offset del pool delle texture: il file non effettua deduplicazioni esplicite.

6. Nome dell'oggetto (0x1A0..0x1A4)

424f 5800   →  "BOX\0"

Dimensione totale del file: 0x1A4 = 420 byte. ✓

Rileggerlo con kbot

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

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

Esempi reali nel catalogo di riferimento

Unità ordinate per complessità del 3DO (il campo Objectname nell'FBI è il nome del file del modello senza estensione; vedi reference-ta/ta-units.md):

  • Banali: BOX (l'esempio guidato qui sopra; 420 byte, 8 vertici, 6 quadrilateri), la maggior parte delle mine (ARMMINE1–6, CORMINE1–6).
  • A pezzo singolo: ARMRAD (radar), ARMTIDE (generatore di marea): mesh di un solo oggetto, utili per studiare la disposizione delle primitive.
  • Kbot bipedi: ARMCK, ARMHAM, ARMPW: gerarchie a più pezzi da confrontare con lo script Walk nei loro COB.
  • Veicoli con torretta: ARMBULL, ARMSAM, CORREAP: mostrano lo schema padre/figlio turret1 → barrel1.
  • I modelli più grandi: CORKROG (Krogoth) e ARMCOM (Commander), i file .3do più complessi del gioco.

Vedi anche

  • COB: comandi degli script che animano i pezzi del 3DO per nome.
  • GAF: pagine di texture (anims/textures.gaf) a cui fanno riferimento le primitive del 3DO.
  • PAL: la palette usata per colorare le primitive senza texture.
  • FBI: definizioni delle unità che fanno riferimento ai modelli 3DO tramite ObjectName (= nome del file 3DO senza estensione).
  • Glossario: pezzo (piece), oggetto di ancoraggio (anchor object), virgola fissa (fixed-point).