3DO: modelos 3D jerárquicos
Fuente: coreprime/kbot @ 0ab97bf · Editar en GitHub
Un archivo
.3does un árbol de objetos 3D: vértices, primitivas y referencias a texturas organizados en una jerarquía de padres e hijos. Lo usan todas las unidades del juego, todos los elementos estáticos del mapa que tienen geometría y todos los proyectiles con modelo 3D. Un 3DO no contiene datos de animación; el script COB de la unidad mueve las piezas durante la partida.
Tip
Pruébalo tú mismo.
# 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 la referencia de kbot mount de la CLI.
Desde 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))
}
Por qué 3DO está construido así
.3do fue la respuesta de Cavedog a la pregunta «¿cómo dejamos que los
scripts animen una unidad sin cálculos matriciales costosos?», y la
respuesta es jerarquía + transformaciones por pieza. La unidad es un
árbol de piezas con nombre:
armcom (root)
├── pelvis
│ ├── torso
│ │ ├── lupperarm
│ │ │ └── lforearm
│ │ │ └── lhand
│ │ ├── rupperarm ...
│ │ └── head
│ ├── lthigh
│ │ └── lleg
│ └── rthigh
│ └── rleg
└── ...
Cuando el script COB dice turn rupperarm to x-axis <30> speed <60>;, el
motor gira exactamente esa pieza, y todo lo que cuelga de ella (antebrazo,
mano, boca del arma) la sigue automáticamente. No hay pesos de skinning ni
huesos en el sentido moderno; cada pieza es una submalla rígida unida a su
padre con un desplazamiento fijo.
Estructura en disco
Cada objeto es una cabecera de 52 bytes seguida de los datos de sus arrays con nombre. La cabecera contiene los desplazamientos de sus hijos, hermanos, vértices y primitivas.
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 vértice son tres componentes int32 en coma fija:
typedef struct { int32 X, Y, Z; } Vertex;
La convención de coma fija es parte entera = unidad del mundo del juego; divide por 65 536 para la precisión por debajo de la unidad. Las unidades de Cavedog suelen medir de decenas a cientos de unidades del mundo de ancho.
Una primitiva es una cara (o un solo punto, o una línea):
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;
El array de índices de vértices al que apunta OffsetToVertexIndexArray
es una lista plana de índices uint16 en el array de vértices local del
objeto (no en un pool global).
Recorrido de la jerarquía
Cada cabecera de objeto apunta al primer hijo y al siguiente hermano. Es la clásica codificación de árboles «primer hijo / siguiente hermano», con la que es fácil escribir un recorrido en profundidad:
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)
El objeto raíz está en el desplazamiento 0 del archivo. En todos los
modelos de la versión comercial su SiblingOffset es 0, pero el juego
también sigue la cadena de hermanos de la raíz: cada hermano de la raíz (y
su subárbol) se carga como una pieza de nivel superior más, después del
subárbol de la raíz, y se dibuja en sus propias coordenadas de vértice: la
transformación de piezas del juego empieza en la raíz y no les aplica
ningún desplazamiento de pieza. El JSON del modelo de kbot los incluye como
rootSiblings, y el explorador de recursos los cuenta y los enumera.
Cada nombre de pieza del script COB debe coincidir con un nombre de pieza
de este árbol (sin distinguir mayúsculas); si no coincide, la animación del
script no hará nada, sin avisar.
Texturizado
OffsetToTextureName apunta a una cadena ASCII que nombra una secuencia de
anims/textures.gaf (o de su equivalente en TA: Kingdoms). Algunas cosas
que conviene saber:
- No hay coordenadas UV. El motor genera las UV a partir del orden de
los vértices de la primitiva. Para «girar» una textura, se reordenan los
índices de vértice del
VertexIndexArrayde la primitiva. El editor original 3do Builder lo ofrecía como un control de «orientación de la textura». IsColoredes una palabra de flags y solo importa el bit 0. Más de la mitad de las primitivas de la versión comercial guardan en él otros valores (caras texturizadas con el bit 0 a cero, caras sin textura con el bit 0 activado). Una cara con el bit 0 activado se rellena con el byte bajo deColorIndex.OffsetToTextureName == 0significa sin textura. Si además el bit 0 deIsColoredestá a cero, la primitiva no se dibuja: Cavedog lo usa para caras que solo sirven de colisión o de puro anclaje para scripts.- Un nombre de textura que no se resuelve (se busca sin distinguir
mayúsculas) rellena la cara con el índice de la paleta
0xd1, tenga color o no. - En TA solo se texturizan las caras de cuatro esquinas, con la esquina 0 en el téxel superior izquierdo de la textura y las demás en sentido horario. Un triángulo o un polígono de n lados texturizado y sin color no se dibuja (los modelos de TA: Kingdoms sí los texturizan).
- Las caras solo se dibujan por delante: por el lado hacia el que mira el orden de sus esquinas según la regla de la mano derecha, en las coordenadas del archivo.
- En los archivos creados por Cavedog, el pool de nombres de texturas
empieza en el desplazamiento
0x34del archivo (justo después de la cabecera del objeto raíz). Es una convención, no un requisito del formato.
Important
Cómo dibuja el juego una primitiva (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 */;
Equivocarse en esto es el error de renderizado de 3DO más común: un
modelo con caras solo de colisión acaba relleno de colores aleatorios de
la paleta. El JSON de modelos del visor de unidades de kbot aplica estas
reglas en el servidor (cada primitiva lleva style, fillIndex y
colorRGB, y las caras que el juego nunca dibuja se dejan fuera), y las
vistas previas de objetos del panel de elementos se dibujan con ellas.
Ejemplo desarrollado: armsy.3do (Arm Shipyard)
El recorrido original de Dark Rain usaba el Arm Shipyard. Al leer la cabecera raíz se obtiene:
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)
Al seguir la cadena de hijos y hermanos se obtiene el árbol de piezas:
base
├── turret1
│ └── nano1
│ └── beam1
├── turret2
│ └── nano2
│ └── beam2
├── slip
├── light
├── explode
├── explode1
└── explode2
Algunas hojas, como slip, son objetos de un solo vértice sin
primitivas: solo existen como puntos de anclaje para los scripts COB
(emit-sfx from slip;, move slip to z-axis [-10] speed [20];).
Escollos
Warning
El eje vertical es Y, no Z. Las herramientas de Cavedog y la mayoría de la documentación de terceros usan Y como eje vertical. Si importas un 3DO en un motor con Z hacia arriba (Blender, el Unreal moderno) sin intercambiar los ejes, todas las unidades quedan tumbadas.
- Los vértices son locales al objeto, no globales. Dos objetos pueden tener arrays de vértices idénticos: es señal de geometría reflejada, no de datos compartidos.
OffsetToSelectionPrimes un índice, y esa primitiva nunca se dibuja. El juego la mueve a la posición 0 y se salta la posición 0. Los modelos de la versión comercial también guardan índices en las piezas hijas;-1significa ninguna. El JSON del modelo de kbot conserva la primitiva oculta marcada comohiddeny apuntaselectionPrimhacia ella.Always0es de verdad siempre 0 en el contenido observado. Desde 1998 es el campo de «avisadnos si encontráis un contraejemplo», y nadie lo ha encontrado.- La escala de coma fija es
1 / 65536, es decir, los 16 bits bajos de cada coordenadaint32son la parte fraccionaria. Un vértice escrito como0x00010000vale «1,0 unidades del mundo». - Los nombres de textura no distinguen mayúsculas, pero las conservan. El motor calcula el hash de las cadenas en minúsculas; el archivo guarda las mayúsculas y minúsculas que produjera el editor.
- TA: Kingdoms usa una variante evolucionada con datos adicionales por
primitiva y un modelo de skinning de verdad. kbot no la analiza por
ahora; el proyecto iiCompleteDestruction (consulta el
CLAUDE.mdde kbot) tiene código funcional de mallas de TA:K.
Tamaños habituales
| Recurso | Rango observado en los objects3d/*.3do de Cavedog |
|---|---|
| Tamaño del archivo | 400 B – 60 KB |
| Vértices por modelo | 8 – 1000 (comandante: ~250) |
| Primitivas por modelo | 6 – 800 |
| Piezas por modelo | 1 – 80 (Krogoth) |
box.3do (cubo trivial) |
420 B |
armcom.3do (Arm Commander) |
~25 KB |
corkrog.3do (Krogoth) |
~60 KB |
| Cabecera por objeto | siempre 52 bytes |
| Cabecera por primitiva | siempre 32 bytes |
Apéndice: disección hexadecimal completa de objects3d/box.3do
box.3do es un cubo de 420 bytes: un objeto raíz, 8 vértices, 6 caras
cuadriláteras, 4 texturas distintas y ningún hijo. Es lo bastante pequeño
para recorrerlo de principio a fin y ejercita todos los campos del
formato.
Volcado hexadecimal 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. Cabecera del objeto raíz (0x00..0x34)
| Desplazamiento | Bytes | Campo | Valor |
|---|---|---|---|
0x00 |
01 00 00 00 |
VersionSignature |
1 ✓ |
0x04 |
08 00 00 00 |
NumberOfVertexes |
8 (esquinas del cubo) |
0x08 |
06 00 00 00 |
NumberOfPrimitives |
6 (caras 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 raíz no tiene hermanos) |
0x30 |
00 00 00 00 |
OffsetToChildObject |
0 (hoja: sin hijos) |
2. Pool de nombres de texturas (0x34..0x50)
Cuatro cadenas terminadas en NUL, una detrás de otra:
| Desplazamiento | Cadena |
|---|---|
0x34 |
"ArmV3b" |
0x3B |
"ArmV3c" |
0x42 |
"ArmV3a" |
0x49 |
"ArmV3d" |
Hacen referencia a secuencias de anims/textures.gaf (o de una hoja de
paleta de TA:K). La convención de Cavedog coloca el pool de texturas en
0x34, justo después de la cabecera raíz, pero el formato solo exige que
los desplazamientos sean válidos; las cadenas pueden estar en cualquier
sitio.
3. Array de índices de vértices (0x50..0x80)
Un bloque plano uint16[24]: seis grupos de cuatro índices, uno por cada
cara cuadrilátera. Leyendo de dos en dos bytes:
| Desplazamiento | Índices | Lo usa la 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 |
Cada fila son «las cuatro esquinas (en sentido antihorario) de una cara del cubo», expresadas como índices en el array de vértices de abajo.
4. Array de vértices (0x80..0xE0)
Ocho vértices × (int32 X, int32 Y, int32 Z) = 96 bytes. Leyendo los tres
primeros en little-endian:
| # | Bytes | (X, Y, Z) como int32 con signo |
|---|---|---|
| 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 en X y Z sitúa las esquinas a ±4096 unidades de coma fija. Y va
de 0 (la base) a +0x1F8000 (la parte superior del cubo). Aplica la
escala de coma fija de 1/65 536 y obtienes un cubo de unas 0,0625 unidades
del mundo en X/Z, con Y llegando a unas 31 unidades del mundo de altura:
las herramientas de creación de TA usan una convención de Y estirada para
los «objetos altos».
5. Array de primitivas (0xE0..0x1A0)
Seis primitivas × 32 bytes. Leyendo la primitiva 0 en 0xE0:
| Desplazamiento | Bytes | Campo | Valor |
|---|---|---|---|
0xE0 |
00 00 00 00 |
ColorIndex |
0 (se ignora: tiene textura) |
0xE4 |
04 00 00 00 |
NumberOfVertexIndexes |
4 (cuadrilátero) |
0xE8 |
00 00 00 00 |
Always0 |
0 |
0xEC |
50 00 00 00 |
OffsetToVertexIndexArray |
0x50 (la primera cuaterna de arriba) |
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 textura) |
Resumen de las seis primitivas:
| # | Desplazamiento | VtxIdxArray | TextureName | Textura |
|---|---|---|---|---|
| 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 |
Tres caras comparten la textura ArmV3c y las otras tres tienen una
propia. Reutilizar una textura no es más que reutilizar un desplazamiento
del pool de texturas: el archivo no elimina duplicados de forma explícita.
6. Nombre del objeto (0x1A0..0x1A4)
424f 5800 → "BOX\0"
Tamaño total del archivo: 0x1A4 = 420 bytes. ✓
Cómo leerlo con kbot
# The web UI dissects 3DOs interactively
kbot mount $(kbot ctx path) --server
# → browse to objects3d/box.3do
Desde 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]
Ejemplos reales en el catálogo de referencia
Unidades ordenadas por la complejidad de su 3DO (el campo Objectname del
FBI es el nombre de archivo del modelo sin extensión; consulta
reference-ta/ta-units.md):
- Triviales:
BOX(el recorrido de arriba; 420 bytes, 8 vértices, 6 cuadriláteros) y la mayoría de las minas (ARMMINE1–6,CORMINE1–6). - De una sola pieza:
ARMRAD(radar),ARMTIDE(generador mareomotriz); mallas de un solo objeto que van bien para estudiar la estructura de las primitivas. - Kbots bípedos:
ARMCK,ARMHAM,ARMPW; jerarquías de varias piezas que puedes comparar con el scriptWalkde sus COB. - Vehículos con torreta:
ARMBULL,ARMSAM,CORREAP; muestran el patrón padre/hijoturret1 → barrel1. - Los modelos más grandes:
CORKROG(Krogoth) yARMCOM(Commander), los archivos.3domás complejos del juego.
Véase también
- COB: órdenes de script que animan las piezas de un 3DO por su nombre.
- GAF: páginas de texturas (
anims/textures.gaf) a las que hacen referencia las primitivas 3DO. - PAL: la paleta con la que se colorean las primitivas sin textura.
- FBI: definiciones de unidades que hacen referencia a modelos
3DO mediante
ObjectName(= nombre de archivo del 3DO sin extensión). - Glosario: piece, anchor object, fixed-point.