3DO – Hierarchische 3D-Modelle
Eine
.3do-Datei ist ein Baum aus 3D-Objekten – Vertices, Primitive und Texturverweise, angeordnet in einer Eltern-Kind-Hierarchie. Jede Einheit im Spiel, jedes statische Kartenobjekt mit Geometrie und jedes Projektil mit 3D-Modell verwendet dieses Format. Ein 3DO enthält keine Animationsdaten; die Pieces werden zur Laufzeit vom COB-Skript der Einheit bewegt.
Tip
Probier es selbst.
# 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
Siehe die kbot mount-Referenz der CLI.
Aus Go heraus. Verwende 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))
}
Warum 3DO so aufgebaut ist
.3do war Cavedogs Antwort auf die Frage „Wie lassen wir Skripte eine
Einheit animieren, ohne teure Matrizenrechnung?“ – die Antwort lautet
Hierarchie + Transformationen pro Piece. Die Einheit ist ein Baum aus
benannten Pieces:
armcom (root)
├── pelvis
│ ├── torso
│ │ ├── lupperarm
│ │ │ └── lforearm
│ │ │ └── lhand
│ │ ├── rupperarm ...
│ │ └── head
│ ├── lthigh
│ │ └── lleg
│ └── rthigh
│ └── rleg
└── ...
Wenn das COB-Skript turn rupperarm to x-axis <30> speed <60>; sagt, dreht
die Engine genau dieses Piece – und alles, was darunter hängt (Unterarm,
Hand, Mündung), folgt automatisch. Es gibt keine Skinning-Gewichte und keine
Bones im heutigen Sinn; jedes Piece ist ein starres Teil-Mesh, das mit
festem Abstand zu seinem Elternteil befestigt ist.
Aufbau auf der Platte
Jedes Objekt besteht aus einem 52-Byte-Header, gefolgt von Nutzdaten in benannten Arrays. Der Header enthält Offsets zu seinen Kindern, Geschwistern, Vertices und Primitiven.
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;
Ein Vertex besteht aus drei int32-Komponenten in Festkomma:
typedef struct { int32 X, Y, Z; } Vertex;
Die Festkomma-Konvention lautet ganze Zahl = Einheit der Spielwelt; durch 65.536 teilen für Genauigkeit unterhalb einer Einheit. Cavedogs Einheiten sind typischerweise einige zehn bis einige hundert Welteinheiten groß.
Ein Primitiv ist eine Fläche (oder ein einzelner Punkt oder eine Linie):
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;
Das Vertex-Index-Array, auf das OffsetToVertexIndexArray verweist, ist eine
flache Liste von uint16-Indizes in das lokale Vertex-Array des Objekts
(kein globaler Pool).
Durchlaufen der Hierarchie
Jeder Objekt-Header zeigt auf das erste Kind und das nächste Geschwister. Das ist die klassische Baumkodierung „erstes Kind / nächstes Geschwister“ – für die sich leicht ein Tiefendurchlauf schreiben lässt:
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)
Das Wurzelobjekt liegt am Datei-Offset 0. In jedem Modell der
Verkaufsversion ist sein SiblingOffset gleich 0, aber das Spiel folgt
auch der Geschwisterkette der Wurzel: Jedes Geschwister der Wurzel (samt
seinem Teilbaum) wird nach dem Teilbaum der Wurzel als weiteres Piece auf
oberster Ebene geladen und an seinen eigenen Vertex-Koordinaten gezeichnet –
die Piece-Transformation des Spiels beginnt bei der Wurzel und wendet auf
sie keine Piece-Offsets an. Das Modell-JSON von kbot führt sie als
rootSiblings, und der Asset-Explorer zählt sie und listet sie auf.
Jeder Piece-Name im COB-Skript muss einem Piece-Namen in diesem Baum
entsprechen (ohne Unterscheidung von Groß- und Kleinschreibung); bei einer
Abweichung bewirkt die Animation des Skripts stillschweigend nichts.
Texturierung
OffsetToTextureName zeigt auf einen ASCII-String, der eine Sequenz in
anims/textures.gaf (oder dem Gegenstück in TA: Kingdoms) benennt. Ein paar
Dinge, die man wissen sollte:
- Es gibt keine UV-Koordinaten. Die Engine erzeugt die UVs aus der
Vertex-Reihenfolge des Primitivs. Um eine Textur zu „drehen“, ordnest du
die Vertex-Indizes im
VertexIndexArraydes Primitivs um. Der ursprüngliche Editor 3do Builder bot dafür einen Regler „Texturausrichtung“. IsColoredist ein Flag-Wort, und nur Bit 0 zählt. Mehr als die Hälfte der Primitive der Verkaufsversion speichern darin andere Werte (texturierte Flächen mit gelöschtem Bit 0, untexturierte mit gesetztem Bit 0). Eine Fläche mit gesetztem Bit 0 wird mit dem unteren Byte vonColorIndexgefüllt.OffsetToTextureName == 0bedeutet keine Textur. Ist auch Bit 0 vonIsColoredgelöscht, wird das Primitiv nicht gezeichnet – Cavedog verwendet das für reine Kollisionsflächen oder Flächen, die nur als Anker für Skripte dienen.- Ein Texturname, der sich nicht auflösen lässt (die Suche ignoriert
Groß- und Kleinschreibung), füllt die Fläche mit Palettenindex
0xd1, ob gefärbt oder nicht. - Nur Flächen mit vier Ecken werden in TA texturiert, Ecke 0 am linken oberen Texel der Textur und dann im Uhrzeigersinn. Ein ungefärbtes texturiertes Dreieck oder n-Eck wird nicht gezeichnet (Modelle von TA: Kingdoms texturieren sie).
- Flächen werden nur von vorn gezeichnet: von der Seite, in die ihre Eckenreihenfolge nach der Rechte-Hand-Regel zeigt, in Dateikoordinaten.
- In von Cavedog erstellten Dateien beginnt der Pool der Texturnamen am
Datei-Offset
0x34(direkt hinter dem Header des Wurzelobjekts). Das ist eine Konvention, keine Anforderung des Formats.
Important
Wie das Spiel ein Primitiv zeichnet (Primitive.Style in 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 */;
Das falsch zu machen ist der häufigste Render-Bug bei 3DO – ein Modell mit
reinen Kollisionsflächen wird am Ende mit zufälligen Palettenfarben
gefüllt. Das Modell-JSON des Einheiten-Viewers von kbot wendet diese Regeln
auf dem Server an (jedes Primitiv trägt style, fillIndex und
colorRGB, und Flächen, die das Spiel nie zeichnet, werden weggelassen),
und die Objektvorschauen des Kartenobjekt-Zeichners rendern damit.
Beispiel im Detail – armsy.3do (Arm Shipyard)
Die ursprüngliche Beschreibung von Dark Rain verwendete die Arm Shipyard. Das Lesen des Wurzel-Headers ergibt:
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)
Folgt man der Kinder-/Geschwisterkette, ergibt sich der Piece-Baum:
base
├── turret1
│ └── nano1
│ └── beam1
├── turret2
│ └── nano2
│ └── beam2
├── slip
├── light
├── explode
├── explode1
└── explode2
Manche Blätter wie slip sind Objekte mit einem einzigen Vertex und null
Primitiven – sie existieren nur als Ankerpunkte für COB-Skripte
(emit-sfx from slip;, move slip to z-axis [-10] speed [20];).
Stolperfallen
Warning
Oben ist Y, nicht Z. Cavedogs Werkzeuge und die meisten Dokumente von Drittanbietern verwenden Y für die vertikale Achse. Importierst du ein 3DO ohne Achsentausch in eine Engine mit Z nach oben (Blender, aktuelle Unreal-Versionen), liegt jede Einheit auf der Seite.
- Vertices sind lokal zum Objekt, nicht global. Zwei Objekte können identische Vertex-Arrays haben – das ist ein Zeichen für gespiegelte Geometrie, nicht für gemeinsam genutzte Daten.
OffsetToSelectionPrimist ein Index, und dieses Primitiv wird nie gezeichnet. Das Spiel verschiebt es auf Platz 0 und überspringt Platz 0. Modelle der Verkaufsversion speichern auch bei Kind-Pieces Indizes;-1bedeutet keines. Das Modell-JSON von kbot behält das verborgene Primitiv mit dem Flaghiddenbei und lässtselectionPrimdarauf zeigen.Always0ist in allen untersuchten Inhalten wirklich immer 0. Seit 1998 ist es das Feld „sagt uns Bescheid, wenn ihr ein Gegenbeispiel findet“, und niemand hat eines gefunden.- Die Festkomma-Skalierung ist
1 / 65536– die unteren 16 Bit jederint32-Koordinate sind also der Nachkommaanteil. Ein als0x00010000geschriebener Vertex liegt bei „1,0 Welteinheiten“. - Bei Texturnamen spielt die Groß-/Kleinschreibung keine Rolle, sie bleibt aber erhalten. Die Engine hasht kleingeschriebene Strings; die Datei speichert die Schreibweise, die der Editor erzeugt hat.
- TA: Kingdoms verwendet eine weiterentwickelte Variante mit
zusätzlichen Daten pro Primitiv und einem echten Skinning-Modell. kbot
parst sie derzeit nicht; das Projekt iiCompleteDestruction (siehe
CLAUDE.mdvon kbot) hat funktionierenden Mesh-Code für TA:K.
Typische Größen
| Asset | Beobachteter Bereich in Cavedogs objects3d/*.3do |
|---|---|
| Dateigröße | 400 B – 60 KB |
| Vertices pro Modell | 8–1.000 (Commander: ~250) |
| Primitive pro Modell | 6–800 |
| Pieces pro Modell | 1–80 (Krogoth) |
box.3do (trivialer Würfel) |
420 B |
armcom.3do (Arm Commander) |
~25 KB |
corkrog.3do (Krogoth) |
~60 KB |
| Header pro Objekt | immer 52 Byte |
| Header pro Primitiv | immer 32 Byte |
Anhang – Vollständige Hex-Zerlegung von objects3d/box.3do
box.3do ist ein 420 Byte großer Würfel – ein Wurzelobjekt, 8 Vertices,
6 viereckige Flächen, 4 verschiedene Texturen, keine Kinder. Er ist klein
genug, um ihn von Anfang bis Ende durchzugehen, und berührt jedes Feld des
Formats.
Vollständiger Hexdump:
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. Header des Wurzelobjekts (0x00..0x34)
| Offset | Bytes | Feld | Wert |
|---|---|---|---|
0x00 |
01 00 00 00 |
VersionSignature |
1 ✓ |
0x04 |
08 00 00 00 |
NumberOfVertexes |
8 (Würfelecken) |
0x08 |
06 00 00 00 |
NumberOfPrimitives |
6 (Würfelflächen) |
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 (die Wurzel hat kein Geschwister) |
0x30 |
00 00 00 00 |
OffsetToChildObject |
0 (Blatt – keine Kinder) |
2. Pool der Texturnamen (0x34..0x50)
Vier NUL-terminierte Strings, lückenlos hintereinander gepackt:
| Offset | String |
|---|---|
0x34 |
"ArmV3b" |
0x3B |
"ArmV3c" |
0x42 |
"ArmV3a" |
0x49 |
"ArmV3d" |
Diese verweisen auf Sequenzen in anims/textures.gaf (oder in einem
Palettenblatt von TA:K). Nach Cavedogs Konvention liegt der Texturpool bei
0x34 – direkt hinter dem Wurzel-Header –, das Format verlangt aber nur,
dass die Offsets gültig sind; die Strings können überall liegen.
3. Vertex-Index-Array (0x50..0x80)
Ein flacher Block uint16[24] – sechs Läufe aus je vier Indizes, ein Lauf
pro viereckiger Fläche. Liest man jeweils zwei Bytes:
| Offset | Indizes | Verwendet von Primitiv |
|---|---|---|
0x50 |
1, 2, 0, 3 |
Primitiv 0 |
0x58 |
2, 6, 4, 0 |
Primitiv 1 |
0x60 |
6, 2, 1, 5 |
Primitiv 2 |
0x68 |
5, 1, 3, 7 |
Primitiv 3 |
0x70 |
0, 4, 7, 3 |
Primitiv 4 |
0x78 |
6, 5, 7, 4 |
Primitiv 5 |
Jede Zeile ist „die vier Ecken (gegen den Uhrzeigersinn) einer Würfelfläche“, ausgedrückt als Indizes in das Vertex-Array darunter.
4. Vertex-Array (0x80..0xE0)
Acht Vertices × (int32 X, int32 Y, int32 Z) = 96 Byte. Die ersten drei,
in Little-Endian dekodiert:
| # | Bytes | (X, Y, Z) als vorzeichenbehaftetes 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 auf X und Z setzen die Ecken auf ±4096 Festkomma-Einheiten. Y
reicht von 0 (Boden) bis +0x1F8000 (Oberseite des Würfels). Wendet man
die Festkomma-Skalierung 1/65536 an, erhält man einen Würfel von etwa
0,0625 Welteinheiten in X/Z, dessen Y-Höhe etwa 31 Welteinheiten erreicht –
die Autorenwerkzeuge von TA verwenden für „hohe Requisiten“ eine Konvention
mit gestrecktem Y.
5. Primitiv-Array (0xE0..0x1A0)
Sechs Primitive × 32 Byte. Primitiv 0 bei 0xE0 gelesen:
| Offset | Bytes | Feld | Wert |
|---|---|---|---|
0xE0 |
00 00 00 00 |
ColorIndex |
0 (ignoriert – texturiert) |
0xE4 |
04 00 00 00 |
NumberOfVertexIndexes |
4 (Viereck) |
0xE8 |
00 00 00 00 |
Always0 |
0 |
0xEC |
50 00 00 00 |
OffsetToVertexIndexArray |
0x50 (das erste 4-Tupel oben) |
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 (texturiert) |
Alle sechs Primitive zusammengefasst:
| # | Offset | VtxIdxArray | TextureName | Textur |
|---|---|---|---|---|
| 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 |
Drei Flächen teilen sich die Textur ArmV3c, die anderen drei bekommen
jeweils eine eigene. Die Wiederverwendung einer Textur ist schlicht die
Wiederverwendung eines Offsets im Texturpool – die Datei dedupliziert nicht
ausdrücklich.
6. Objektname (0x1A0..0x1A4)
424f 5800 → "BOX\0"
Gesamte Dateigröße: 0x1A4 = 420 Byte. ✓
Mit kbot zurücklesen
# The web UI dissects 3DOs interactively
kbot mount $(kbot ctx path) --server
# → browse to objects3d/box.3do
Aus Go heraus (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]
Echte Beispiele im Referenzkatalog
Einheiten nach Komplexität ihres 3DO sortiert (das Feld Objectname in der
FBI ist der Dateiname des Modells ohne Endung – siehe
reference-ta/ta-units.md):
- Trivial –
BOX(das Beispiel oben; 420 Byte, 8 Vertices, 6 Vierecke), die meisten Minen (ARMMINE1–6,CORMINE1–6). - Ein einziges Piece –
ARMRAD(Radar),ARMTIDE(Gezeitengenerator) – Meshes aus einem Objekt, gut geeignet, um den Aufbau von Primitiven zu studieren. - Zweibeinige Kbots –
ARMCK,ARMHAM,ARMPW– Hierarchien aus mehreren Pieces, die du mit dem SkriptWalkin ihren COBs vergleichen kannst. - Fahrzeuge mit Geschütztürmen –
ARMBULL,ARMSAM,CORREAP– zeigen das Eltern-Kind-Musterturret1 → barrel1. - Größte Modelle –
CORKROG(Krogoth) undARMCOM(Commander) – die komplexesten.3do-Dateien im Spiel.
Siehe auch
- COB – Skriptbefehle, die 3DO-Pieces über ihren Namen animieren.
- GAF – Texturseiten (
anims/textures.gaf), auf die 3DO-Primitive verweisen. - PAL – die Palette, mit der untexturierte Primitive gefärbt werden.
- FBI – Einheitendefinitionen, die über
ObjectName(= Dateiname des 3DO ohne Endung) auf 3DO-Modelle verweisen. - Glossar – piece, anchor object, fixed-point.