3DO — Modelli 3D gerarchici
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
VertexIndexArraydella 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 diColorIndex.OffsetToTextureName == 0significa nessuna texture. Se anche il bit 0 diIsColoredè 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
0x34del 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;-1significa nessuna. Il JSON del modello di kbot mantiene la primitiva nascosta, contrassegnata comehidden, e fa puntareselectionPrima essa.Always0vale 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 coordinataint32sono la parte frazionaria. Un vertice scritto0x00010000corrisponde 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.mddi 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 scriptWalknei loro COB. - Veicoli con torretta:
ARMBULL,ARMSAM,CORREAP: mostrano lo schema padre/figlioturret1 → barrel1. - I modelli più grandi:
CORKROG(Krogoth) eARMCOM(Commander), i file.3dopiù 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).