Todas as páginas de formatos ↓
Unidades, scripts e configurações

3DO: modelos 3D hierárquicos

Fonte: coreprime/kbot @ 0ab97bf · Editar no GitHub

Um arquivo .3do é uma árvore de objetos 3D: vértices, primitivas e referências de textura organizados numa hierarquia de pais e filhos. Todas as unidades do jogo, todos os elementos estáticos do mapa que têm geometria e todos os projéteis com modelo 3D usam este formato. Um 3DO não tem dados de animação: as peças são movidas durante o jogo pelo script COB da unidade.

Tip

Experimente você mesmo.

# 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

Veja a referência do kbot mount da linha de comando.

Em Go. Use 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 que o 3DO é construído assim

O .3do foi a resposta da Cavedog à pergunta "como deixar os scripts animarem uma unidade sem cálculos caros com matrizes?". A resposta é hierarquia + transformações por peça. A unidade é uma árvore de peças com nome:

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

Quando o script COB diz turn rupperarm to x-axis <30> speed <60>;, o motor gira exatamente essa peça, e tudo o que está preso abaixo dela (antebraço, mão, cano da arma) acompanha automaticamente. Não há pesos de skinning nem ossos no sentido moderno: cada peça é uma submalha rígida presa a um deslocamento fixo em relação à peça mãe.


Estrutura no disco

Cada objeto é um cabeçalho de 52 bytes seguido de arrays de dados referenciados pelo cabeçalho. O cabeçalho traz os offsets dos filhos, dos irmãos, dos vértices e das 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;

Um vértice são três componentes int32 em ponto fixo:

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

A convenção de ponto fixo é: a parte inteira é a unidade do mundo do jogo; divida por 65.536 para ter precisão abaixo da unidade. As unidades da Cavedog costumam medir de dezenas a centenas de unidades do mundo.

Uma primitiva é uma face (ou um único ponto, ou uma linha):

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;

O array de índices de vértices referenciado por OffsetToVertexIndexArray é uma lista simples de índices uint16 no array de vértices local do objeto (não num pool global).


Percurso da hierarquia

O cabeçalho de cada objeto aponta para o primeiro filho e para o próximo irmão. É a codificação clássica de árvore "primeiro filho / próximo irmão", fácil de percorrer em profundidade:

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)

O objeto raiz fica no offset 0 do arquivo. Em todos os modelos da versão comercial, o seu SiblingOffset é 0, mas o jogo também segue a cadeia de irmãos da raiz: cada irmão da raiz (com a sua subárvore) é carregado como mais uma peça de nível superior depois da subárvore da raiz, e é desenhado nas suas próprias coordenadas de vértices. A transformação de peças do jogo começa na raiz e não aplica a eles nenhum deslocamento de peça. O JSON de modelo do kbot os traz como rootSiblings, e o explorador de recursos os conta e lista. Todo nome de peça no script COB precisa corresponder a um nome de peça desta árvore (sem diferenciar maiúsculas de minúsculas). Uma divergência faz a animação do script simplesmente não fazer nada, sem aviso.


Texturização

OffsetToTextureName aponta para uma string ASCII que dá nome a uma sequência em anims/textures.gaf (ou no equivalente do TA: Kingdoms). Algumas coisas que é bom saber:

  • Não há coordenadas UV. O motor gera as UVs a partir da ordem dos vértices da primitiva. Para "girar" uma textura, reordene os índices de vértices no VertexIndexArray da primitiva. O editor original 3do Builder oferecia isso como um controle de "orientação da textura".
  • IsColored é um campo de flags, e só o bit 0 importa. Mais da metade das primitivas da versão comercial guarda outros valores nele (faces texturizadas com o bit 0 desligado, faces sem textura com o bit 0 ligado). Uma face com o bit 0 ligado é preenchida com o byte baixo de ColorIndex.
  • OffsetToTextureName == 0 significa sem textura. Se o bit 0 de IsColored também estiver desligado, a primitiva não é desenhada: a Cavedog usa isso em faces só de colisão ou que servem apenas de âncora para o script.
  • Um nome de textura que não é encontrado (a busca não diferencia maiúsculas de minúsculas) preenche a face com o índice da paleta 0xd1, colorida ou não.
  • No TA, só as faces de quatro cantos são texturizadas, com o canto 0 no texel superior esquerdo da textura e os demais em sentido horário. Um triângulo ou polígono de n lados texturizado e sem cor não é desenhado (os modelos do TA: Kingdoms os texturizam).
  • As faces são desenhadas só pela frente: o lado para o qual a ordem dos cantos aponta pela regra da mão direita, nas coordenadas do arquivo.
  • Nos arquivos feitos pela Cavedog, o pool de nomes de textura começa no offset 0x34 do arquivo (logo depois do cabeçalho do objeto raiz). É uma convenção, não uma exigência do formato.

Important

Como o jogo desenha uma primitiva (Primitive.Style do 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 */;

Errar isso é o bug de renderização de 3DO mais comum: um modelo com faces só de colisão acaba preenchido com cores aleatórias da paleta. O JSON de modelo do visualizador de unidades do kbot aplica essas regras no servidor (cada primitiva traz style, fillIndex e colorRGB, e as faces que o jogo nunca desenha ficam de fora), e as prévias de objetos do painel de elementos são renderizadas com elas.


Exemplo comentado: armsy.3do (Estaleiro ARM)

O tutorial original da Dark Rain usava o Estaleiro ARM. A leitura do cabeçalho raiz dá:

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)

Seguindo a cadeia de filhos e irmãos, chega-se à árvore de peças:

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

Algumas folhas, como slip, são objetos de um único vértice e nenhuma primitiva: existem só como pontos de ancoragem para os scripts COB (emit-sfx from slip;, move slip to z-axis [-10] speed [20];).


Armadilhas

Warning

O eixo vertical é o Y, não o Z. As ferramentas da Cavedog e a maioria da documentação de terceiros usam o Y como eixo vertical. Se você importar um 3DO num motor com Z para cima (Blender, o Unreal moderno) sem trocar os eixos, todas as unidades ficam deitadas de lado.

  • Os vértices são locais ao objeto, não globais. Dois objetos podem ter arrays de vértices idênticos: isso indica geometria espelhada, não dados compartilhados.
  • OffsetToSelectionPrim é um índice, e essa primitiva nunca é desenhada. O jogo a move para a posição 0 e pula a posição 0. Os modelos da versão comercial também guardam índices nas peças filhas; -1 significa nenhuma. O JSON de modelo do kbot mantém a primitiva oculta marcada como hidden e faz selectionPrim apontar para ela.
  • Always0 é mesmo sempre 0 em todo o conteúdo observado. Desde 1998 é o campo do "avise se encontrar um contraexemplo", e ninguém encontrou.
  • A escala do ponto fixo é 1 / 65536, ou seja, os 16 bits baixos de cada coordenada int32 são a parte fracionária. Um vértice gravado como 0x00010000 vale "1,0 unidade do mundo".
  • Os nomes de textura não diferenciam maiúsculas de minúsculas, mas as preservam. O motor calcula o hash das strings em minúsculas. O arquivo guarda a grafia que o editor produziu.
  • O TA: Kingdoms usa uma variante evoluída, com dados adicionais por primitiva e um modelo de skinning de verdade. O kbot ainda não a lê. O projeto iiCompleteDestruction (veja o CLAUDE.md do kbot) tem código de malhas do TA:K que funciona.

Tamanhos típicos

Recurso Faixa observada em objects3d/*.3do da Cavedog
Tamanho do arquivo 400 B – 60 KB
Vértices por modelo 8 – 1.000 (Comandante: ~250)
Primitivas por modelo 6 – 800
Peças por modelo 1 – 80 (Krogoth)
box.3do (cubo trivial) 420 B
armcom.3do (Comandante ARM) ~25 KB
corkrog.3do (Krogoth) ~60 KB
Cabeçalho por objeto sempre 52 bytes
Cabeçalho por primitiva sempre 32 bytes

Apêndice: dissecação hexadecimal completa de objects3d/box.3do

box.3do é um cubo de 420 bytes: um objeto raiz, 8 vértices, 6 faces quadriláteras, 4 texturas diferentes, nenhum filho. É pequeno o bastante para ser percorrido do início ao fim e usa todos os campos do formato.

Dump 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. Cabeçalho do objeto raiz (0x00..0x34)

Offset Bytes Campo Valor
0x00 01 00 00 00 VersionSignature 1 ✓
0x04 08 00 00 00 NumberOfVertexes 8 (cantos do cubo)
0x08 06 00 00 00 NumberOfPrimitives 6 (faces do 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 (a raiz não tem irmão)
0x30 00 00 00 00 OffsetToChildObject 0 (folha, sem filhos)

2. Pool de nomes de textura (0x34..0x50)

Quatro strings terminadas em NUL, uma colada na outra:

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

Elas referenciam sequências em anims/textures.gaf (ou numa folha de paleta do TA:K). A convenção da Cavedog põe o pool de texturas em 0x34, logo depois do cabeçalho raiz, mas o formato só exige que os offsets sejam válidos: as strings podem ficar em qualquer lugar.

3. Array de índices de vértices (0x50..0x80)

Um bloco simples uint16[24]: seis grupos de quatro índices, um grupo por face quadrilátera. Lendo dois bytes de cada vez:

Offset Índices Usado pela 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 linha é "os quatro cantos (em sentido anti-horário) de uma face do cubo", expressos como índices no array de vértices abaixo.

4. Array de vértices (0x80..0xE0)

Oito vértices × (int32 X, int32 Y, int32 Z) = 96 bytes. Lendo os três primeiros em little-endian:

# Bytes (X, Y, Z) como int32 com sinal
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 em X e Z põem os cantos a ±4.096 unidades de ponto fixo. O Y vai de 0 (base) a +0x1F8000 (topo do cubo). Aplicando a escala de ponto fixo 1/65.536, obtém-se um cubo de cerca de 0,0625 unidade do mundo em X/Z, com Y chegando a cerca de 31 unidades do mundo de altura: as ferramentas de criação do TA usam uma convenção de Y esticado para "objetos altos".

5. Array de primitivas (0xE0..0x1A0)

Seis primitivas × 32 bytes. Lendo a primitiva 0 em 0xE0:

Offset Bytes Campo Valor
0xE0 00 00 00 00 ColorIndex 0 (ignorado: texturizada)
0xE4 04 00 00 00 NumberOfVertexIndexes 4 (quadrilátero)
0xE8 00 00 00 00 Always0 0
0xEC 50 00 00 00 OffsetToVertexIndexArray 0x50 (o primeiro grupo de 4 acima)
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 (texturizada)

Resumo das seis primitivas:

# Offset 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

Três faces compartilham a textura ArmV3c, e as outras três têm texturas únicas. Reaproveitar uma textura é só reaproveitar o offset no pool de texturas: o arquivo não elimina duplicatas de forma explícita.

6. Nome do objeto (0x1A0..0x1A4)

424f 5800   →  "BOX\0"

Tamanho total do arquivo: 0x1A4 = 420 bytes. ✓

Ler de volta com o kbot

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

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

Exemplos reais no catálogo de referência

Unidades em ordem de complexidade do 3DO (o campo Objectname do FBI é o nome do arquivo do modelo sem a extensão; veja reference-ta/ta-units.md):

  • Triviais: BOX (o passo a passo acima; 420 bytes, 8 vértices, 6 quadriláteros), a maioria das minas (ARMMINE1–6, CORMINE1–6).
  • De uma só peça: ARMRAD (radar), ARMTIDE (gerador maremotriz), malhas de um único objeto, boas para estudar a disposição das primitivas.
  • Kbots bípedes: ARMCK, ARMHAM, ARMPW, hierarquias de várias peças que você pode comparar com o script Walk dos seus COBs.
  • Veículos com torre: ARMBULL, ARMSAM, CORREAP, que mostram o padrão pai/filho turret1 → barrel1.
  • Os maiores modelos: CORKROG (Krogoth) e ARMCOM (Comandante), os arquivos .3do mais complexos do jogo.

Veja também

  • COB: os comandos de script que animam as peças do 3DO pelo nome.
  • GAF: as páginas de texturas (anims/textures.gaf) referenciadas pelas primitivas do 3DO.
  • PAL: a paleta usada para colorir as primitivas sem textura.
  • FBI: as definições de unidades que referenciam os modelos 3DO por ObjectName (= nome do arquivo 3DO sem a extensão).
  • Glossário: piece (peça), anchor object (objeto de ancoragem), fixed-point (ponto fixo).