Alle Formatseiten ↓
Einheiten, Skripte, Konfiguration

3DO – Hierarchische 3D-Modelle

Aus coreprime/kbot @ 0ab97bf · Auf GitHub bearbeiten

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 VertexIndexArray des Primitivs um. Der ursprüngliche Editor 3do Builder bot dafür einen Regler „Texturausrichtung“.
  • IsColored ist 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 von ColorIndex gefüllt.
  • OffsetToTextureName == 0 bedeutet keine Textur. Ist auch Bit 0 von IsColored gelö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.
  • OffsetToSelectionPrim ist 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; -1 bedeutet keines. Das Modell-JSON von kbot behält das verborgene Primitiv mit dem Flag hidden bei und lässt selectionPrim darauf zeigen.
  • Always0 ist 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 jeder int32-Koordinate sind also der Nachkommaanteil. Ein als 0x00010000 geschriebener 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.md von 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 Skript Walk in ihren COBs vergleichen kannst.
  • Fahrzeuge mit Geschütztürmen – ARMBULL, ARMSAM, CORREAP – zeigen das Eltern-Kind-Muster turret1 → barrel1.
  • Größte Modelle – CORKROG (Krogoth) und ARMCOM (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.